Хакатон Кинопоиск¶

Цели исследования:
На основе данных о постах на телеграм-канале "Кинопоиск", ежедневеом кол-ве подписок и отписок и комментариях к публикациям необходимо:

  • вывести метрики эффективности поста по следующим критериям:
    • Тип публикации
    • Объем текста
    • Отсутствие / наличие эмодзи
    • Отсутствие / наличие изображений
    • Знаки препинания (сложность текста)
  • провести семантический анализ комментариев к постам;
  • проанализировать реакции на публикации;
  • изучить корреляции характеристик и метрик постов с притоком новых пользователей и уровнем вовлеченности; По итогам исследования нужно подготовить рекомендации, на основании которых будет подготовлена вирусная механика привлечения новых подписчиков на канал Кинопоиска и даны рекомендации по повышению уровня вовлеченности и охватов.

Загрузка данных и подготовка к анализу¶

Загрузка библиотек¶

Загрузим необходимые библиотеки.

In [ ]:
!pip install stop-words
!pip install nltk.tokenize
!pip install pymorphy2
Requirement already satisfied: stop-words in /usr/local/lib/python3.10/dist-packages (2018.7.23)
ERROR: Could not find a version that satisfies the requirement nltk.tokenize (from versions: none)
ERROR: No matching distribution found for nltk.tokenize
Requirement already satisfied: pymorphy2 in /usr/local/lib/python3.10/dist-packages (0.9.1)
Requirement already satisfied: dawg-python>=0.7.1 in /usr/local/lib/python3.10/dist-packages (from pymorphy2) (0.7.2)
Requirement already satisfied: pymorphy2-dicts-ru<3.0,>=2.4 in /usr/local/lib/python3.10/dist-packages (from pymorphy2) (2.4.417127.4579844)
Requirement already satisfied: docopt>=0.6 in /usr/local/lib/python3.10/dist-packages (from pymorphy2) (0.6.2)
In [ ]:
#drive.mount('/content/drive')
In [ ]:
import pandas as pd
import matplotlib.pyplot as plt
import datetime as dt
import seaborn as sns
import numpy as np
import requests
import urllib
import json
import re
import warnings
import scipy.stats as stats
import nltk
import string
import pymorphy2
import ast
import random
import statistics


from google.colab import drive
from datetime import datetime, timedelta
from nltk import word_tokenize
from nltk.probability import FreqDist
from nltk.corpus import stopwords
from wordcloud import WordCloud

from scipy.stats import pearsonr, spearmanr
In [ ]:
nltk.download('punkt')
nltk.download('stopwords')
[nltk_data] Downloading package punkt to /root/nltk_data...
[nltk_data]   Package punkt is already up-to-date!
[nltk_data] Downloading package stopwords to /root/nltk_data...
[nltk_data]   Package stopwords is already up-to-date!
Out[ ]:
True

Уберем ограничение по выводу строк, колонок и символов в записи и включаем игнорирование ошибок.

In [ ]:
# Сброс ограничений на количество выводимых рядов
pd.set_option('display.max_rows', None)

# Сброс ограничений на число столбцов
pd.set_option('display.max_columns', None)

# Сброс ограничений на количество символов в записи
pd.set_option('display.max_colwidth', None)

#Игнорируем предупреждения Jupiter
warnings.filterwarnings('ignore')

Загрузка дынных¶

Загружаем датасеты в датафреймы.

In [ ]:
# подготавливаем ссылки для скачивания файлов с Яндекс.Диска.
# публичная ссылка на датасеты на яндекс диске
folder_url = 'https://disk.yandex.ru/d/2MvkGDrpIQjV7Q'
# названия файлов
file_url = ['comments_kinopisk_2023_01_18.csv',
            'kinopisk_reposts_and_mentions_2023_19_01.csv',
            'kinopisk_subscribers_detailed_2024-01-18.csv',
            'kinopoisk_channel_posts_2023-01-21.csv',
            'kinopoisk_subscribers_general_2024_18_01.csv']

# загружаем каждый файл в свой датафрейм
for i in range(0,5):
  url = ('https://cloud-api.yandex.net/v1/disk/public/resources/download'
        + '?public_key='
        + urllib.parse.quote(folder_url)
        + '&path=/'
        + urllib.parse.quote(file_url[i]))
  # запрос ссылки на скачивание
  r = requests.get(url)
   # 'парсинг' ссылки на скачивание
  h = json.loads(r.text)['href']

  if i == 0:
    comments = pd.read_csv(h, index_col=[0])
  elif i == 1:
    reposts_and_mentions = pd.read_csv(h, index_col=[0])
  elif i == 2:
    subscribers_detailed = pd.read_csv(h, index_col=[0])
  elif i == 3:
    channel_posts = pd.read_csv(h, index_col=[0])
  else:
    subscribers_general = pd.read_csv(h, index_col=[0])
In [ ]:
print(channel_posts.info())
display(channel_posts.head())
<class 'pandas.core.frame.DataFrame'>
Int64Index: 23326 entries, 0 to 35656
Data columns (total 12 columns):
 #   Column           Non-Null Count  Dtype  
---  ------           --------------  -----  
 0   channel          23326 non-null  object 
 1   id               23326 non-null  int64  
 2   date             23326 non-null  object 
 3   text             23326 non-null  object 
 4   views            23325 non-null  float64
 5   reactions        9507 non-null   object 
 6   with_media       22594 non-null  object 
 7   forwarded        23325 non-null  float64
 8   replies          12233 non-null  object 
 9   reactions_count  23326 non-null  int64  
 10  comments         23326 non-null  int64  
 11  type_attachment  22594 non-null  object 
dtypes: float64(2), int64(3), object(7)
memory usage: 2.3+ MB
None
channel id date text views reactions with_media forwarded replies reactions_count comments type_attachment
0 https://t.me/kinopoisk 37125 2024-01-21 08:02:21+00:00 Какими подростками были актеры из «Трудных подростков»? А что они помнят о своей первой любви?\n\nПоговорили с кастом сериала — Милой Ершовой, Святославом Рогожаном, Анастасией Красовской (@nastitasti) и Дашей Верещагиной. \n\n[Вспомнили](https://www.kinopoisk.ru/media/article/4008982/) самый яркий съемочный день, любимых героев и не только!\n\n🔥 Подписывайтесь на [**«Кинопоиск»**](https://t.me/kinopoisk) 12744.0 {'_': 'MessageReactions', 'results': [{'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤'}, 'count': 41, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👍'}, 'count': 8, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🐳'}, 'count': 4, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👎'}, 'count': 3, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤\u200d🔥'}, 'count': 1, 'chosen_order': None}], 'min': False, 'can_see_list': False, 'recent_reactions': []} {'_': 'MessageMediaWebPage', 'webpage': {'_': 'WebPage', 'id': 5475316184032202772, 'url': 'https://www.kinopoisk.ru/media/article/4008982/', 'display_url': 'kinopoisk.ru/media/article/4008982', 'hash': 0, 'has_large_media': True, 'type': 'photo', 'site_name': 'Кинопоиск', 'title': 'Актеры «Трудных подростков» вспоминают любимое в сериале и свое взросление. Есть история про котлеты! — Статьи на Кинопоиске', 'description': 'На\xa0Wink заканчивается пятый, финальный сезон «Трудных подростков». Кинопоиск поговорил с актерами\xa0Милой Ершовой, Святославом Рогожаном, Анастасией Красовской и\xa0Дашей Верещагиной о сериале, первой любви и том, какими они были подростками.', 'photo': {'_': 'Photo', 'id': 5867292056869778109, 'access_hash': -514397826960941877, 'file_reference': b'\x00e\xac\xd9\x08\x8d_4\xe4\xcc_\xf7O\xcb\xa7t\x9e\xfd&q\xe7', 'date': datetime.datetime(2024, 1, 21, 7, 5, 38, tzinfo=datetime.timezone.utc), 'sizes': [{'_': 'PhotoStrippedSize', 'type': 'i', 'bytes': b'\x01\x15(\xcd^0\xb8_\xc5E6E\x1b\xcf\xa7\xb5X\xca\xf6\xdb\x81\xdc\xf6\xfd(\x06=\xc0\x12\xa7=\xc7?\xd2\x80+\xacj\xc0\x9c\x91\x8azA\x1b6\x0b\x10*\xe248\xfe\x1ez\xf1\xff\x00\xd6\xa8\xd8\xc6\x18\xec(\x0f\xf9\xf6\xa0\x08\x96\xd1\x1b\x8d\xc78\xa2\x9d\x1b`\xee\xed\xedE\x00W~\x18\xafl\xd2\xa7a\xefE\x14\x00\xf9>U\x18\xea{\xd4hr\xd4QB\x13&NT\xfbQE\x14\x98\xd1'}, {'_': 'PhotoSize', 'type': 'm', 'w': 320, 'h': 168, 'size': 17059}, {'_': 'PhotoSize', 'type': 'x', 'w': 800, 'h': 420, 'size': 71309}, {'_': 'PhotoSizeProgressive', 'type': 'y', 'w': 1200, 'h': 630, 'sizes': [11670, 31872, 50766, 71132, 113336]}], 'dc_id': 4, 'has_stickers': False, 'video_sizes': []}, 'embed_url': None, 'embed_type': None, 'embed_width': None, 'embed_height': None, 'duration': None, 'author': None, 'document': None, 'cached_page': None, 'attributes': []}, 'force_large_media': False, 'force_small_media': False, 'manual': True, 'safe': False} 12.0 {'_': 'MessageReplies', 'replies': 5, 'replies_pts': 1171733, 'comments': True, 'recent_repliers': [{'_': 'PeerUser', 'user_id': 1677466820}, {'_': 'PeerUser', 'user_id': 241368205}, {'_': 'PeerUser', 'user_id': 5583002067}], 'channel_id': 1244684646, 'max_id': 730442, 'read_max_id': None} 57 5 MessageMediaWebPage
4 https://t.me/kinopoisk 37121 2024-01-20 18:01:00+00:00 Фильм дня — [**«Дневник Бриджит Джонс»**](https://www.kinopoisk.ru/film/621/) (18+) 🎬\n\nБриджит Джонс — 32 года, она не замужем, переживает из-за лишнего веса и хочет избавиться от вредных привычек. Чтобы привести свои жизнь в порядок, Бриджит решает вести дневник. Теперь героине предстоит выбрать одного из двух мужчин, с которыми ее сводит жизнь: скромного Марка Дарси или ее босса, харизматичного Дэниэла Кливера. \n\nПолная юмора мелодрама Шэрон Магуайр стала современной версией классического романа Джейн Остин «Гордость и предубеждение». А Бриджит — настоящий иконой ромкомов! Не зря Рене Зеллвегер даже номинировали на «Оскар» за эту роль.\n\n🔥 Подписывайтесь на [**«Кинопоиск»**](https://t.me/kinopoisk) 49486.0 {'_': 'MessageReactions', 'results': [{'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤'}, 'count': 657, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👍'}, 'count': 110, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤\u200d🔥'}, 'count': 55, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '💅'}, 'count': 31, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👎'}, 'count': 16, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🔥'}, 'count': 9, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🤪'}, 'count': 7, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🍌'}, 'count': 4, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '😱'}, 'count': 3, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🐳'}, 'count': 3, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '⚡'}, 'count': 1, 'chosen_order': None}], 'min': False, 'can_see_list': False, 'recent_reactions': []} {'_': 'MessageMediaPhoto', 'spoiler': False, 'photo': {'_': 'Photo', 'id': 5287402653648804899, 'access_hash': -2498810798362553488, 'file_reference': b'\x02>\xd5\xfa\xf9\x00\x00\x91\x01e\xac\xd9\x08va/57;\x89\xab\xfd5\xe6k\x18\x92\x97\x05', 'date': datetime.datetime(2024, 1, 20, 16, 56, 5, tzinfo=datetime.timezone.utc), 'sizes': [{'_': 'PhotoStrippedSize', 'type': 'i', 'bytes': b'\x01(\x1c\xd4,\x81\x8eI\xcf\xe3Ax\xff\x00\xbcx\xfa\xd3]\x0e\xfc\x84\xce}\xe9\x840\x19\xd8\xa0z\x9ac\xd0\x91\x9e \x033\xe0v94\xf1\x82\x01\x1c\x8f\xadd\xbe\xe7\xb8\xda\xe7(\x0e\x08\xad\x0bb\xdeH\xe0\x8eMM\xc2\xc2=\xa2\xbc\xcc\xec\xed\xcfa\xc5\te\x1cn\x18\x16$z\xd5\xaaJv\x15\xd9\x93-\xbf\xfaC\xe4n\\\xf1\x9e\xdd*\xfd\xbcl\x91\xf3&rs\xd2\xaaD\x86M^c\x9f\x919#=r\x05h\xf4\xe0`\n\x12\xd4\x1e\xaa\xc2\x06%\x98mn;\x9e\x86\x99\xe6\x9f\xf9\xe6\xdf\x95\x14S\x02+xc\x8egu\x8d\x95\x9f\x92I\xab\x19\xcfcE\x14\x80'}, {'_': 'PhotoSize', 'type': 'm', 'w': 225, 'h': 320, 'size': 17419}, {'_': 'PhotoSize', 'type': 'x', 'w': 562, 'h': 800, 'size': 63496}, {'_': 'PhotoSizeProgressive', 'type': 'y', 'w': 899, 'h': 1280, 'sizes': [13479, 30447, 37679, 52109, 89252]}], 'dc_id': 2, 'has_stickers': False, 'video_sizes': []}, 'ttl_seconds': None} 161.0 {'_': 'MessageReplies', 'replies': 85, 'replies_pts': 1171733, 'comments': True, 'recent_repliers': [{'_': 'PeerUser', 'user_id': 330254304}, {'_': 'PeerUser', 'user_id': 193630971}, {'_': 'PeerUser', 'user_id': 1295050489}], 'channel_id': 1244684646, 'max_id': 730439, 'read_max_id': None} 896 85 MessageMediaPhoto
5 https://t.me/kinopoisk 37120 2024-01-20 16:01:16+00:00 Кристоферу Нолану [вручат](https://www.hollywoodreporter.com/movies/movie-news/christopher-nolan-honorary-cesar-1235793056/) почетную премию «Сезар» за выдающиеся достижения в кинематографе — главный киноприз Франции. \n\nКак говорится в заявлении Французской киноакадемии, режиссер «переопределяет границы кинематографического совершенства и переносит нас за пределы пространства и времени — выходит за рамки кино, чтобы сделать его незабываемым». \n\nНолан получит награду 23 февраля на 49-й церемонии вручения премии «Сезар». Вместе с ним в этом году почетной статуэтки удостоится французская актриса и сценаристка Аньес Жауи. \n\nНе так давно Нолан получил «Золотой глобус» за лучшую режиссуру, а его «Оппенгеймер» выиграл в категории «Лучший фильм — драма». Картина Нолана о физике Роберте Оппенгеймере уже вошла в шорт-лист «Оскара» и, по прогнозам, должна [получить](https://www.kinopoisk.ru/media/article/4008731/) все основные номинации. \n\n__Заслужил?__\n❤️ — Давно! Гений\n👎 — Не заслужил, пока рано\n\nФото: Kevin Mazur / Getty Images\n\n🔥 Подписывайтесь на [**«Кинопоиск»**](https://t.me/kinopoisk) 53713.0 {'_': 'MessageReactions', 'results': [{'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤'}, 'count': 1585, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👎'}, 'count': 82, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤\u200d🔥'}, 'count': 43, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👍'}, 'count': 40, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🔥'}, 'count': 9, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🏆'}, 'count': 6, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🍾'}, 'count': 5, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🐳'}, 'count': 4, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🤝'}, 'count': 4, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🦄'}, 'count': 3, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🥰'}, 'count': 1, 'chosen_order': None}], 'min': False, 'can_see_list': False, 'recent_reactions': []} {'_': 'MessageMediaPhoto', 'spoiler': False, 'photo': {'_': 'Photo', 'id': 5287757868919019751, 'access_hash': 5101285469346575516, 'file_reference': b'\x02>\xd5\xfa\xf9\x00\x00\x91\x00e\xac\xd9\x08\x0e\xc8\xdb\xdf\x0f6\xcf\x8b\x9d8\t\xe0M\x07\xe4\x08', 'date': datetime.datetime(2024, 1, 20, 15, 24, 15, tzinfo=datetime.timezone.utc), 'sizes': [{'_': 'PhotoStrippedSize', 'type': 'i', 'bytes': b'\x01\x1b(i;\xe6X\xc1 (\xf9s\xf8\xd3\x1d\x17\xc8D\xfe\xe6r\xd8\xe9\xcdGm\x1c\xb3\xc9\xb9@\xe3\xd4\xd2\xcd\x88$a"\x9c\x9ev\x83\xc1\xa9[\x94?\xca2|\xef\xf7@\xc6\rUr\x07\xfa\xbe\xaax\xfaU\xd8\xcb5\x92\xb7\xcb\xb8\xe7\x0b\xeb\x8a\xa2\x88g\x94\xec\\\x13\xce3L\x0b%\x03\xa9\xd9\xc1\x034S\xe1\x8b\xc8<\xb3na\x82\x98\xa2\xa6\xf6\x1d\xae\x16\x04\xe1\xb0\xf8$\xe6\x9bz\x16B\xa4\xbf$\xff\x00\x15VN\x06G\\\xd4\xd7\x00\x10r:\x01\x8a}Ct6\x160\\&\xf7\x1b9\xc7\xa7J-&X\x98\xab\x00\x03\x7f\x1fu\xa8\xb0\x1a.y\xda\x0e=\xb9\xa8\x8fJ\xad\xc8\xd8\xb8\xd2\xce\xf2\xaa\x86#\x92\tS\xd7\x14S\xed\t\x16D\xf7\x07\x8f\xce\x8a\x96ZW?'}, {'_': 'PhotoSize', 'type': 'm', 'w': 320, 'h': 214, 'size': 19837}, {'_': 'PhotoSize', 'type': 'x', 'w': 800, 'h': 534, 'size': 75950}, {'_': 'PhotoSizeProgressive', 'type': 'y', 'w': 1024, 'h': 683, 'sizes': [11012, 26236, 31821, 46980, 81018]}], 'dc_id': 2, 'has_stickers': False, 'video_sizes': []}, 'ttl_seconds': None} 136.0 {'_': 'MessageReplies', 'replies': 68, 'replies_pts': 1171733, 'comments': True, 'recent_repliers': [{'_': 'PeerUser', 'user_id': 497409694}, {'_': 'PeerUser', 'user_id': 431417457}, {'_': 'PeerUser', 'user_id': 432080771}], 'channel_id': 1244684646, 'max_id': 730404, 'read_max_id': None} 1782 68 MessageMediaPhoto
6 https://t.me/kinopoisk 37119 2024-01-20 14:20:04+00:00 Отгадайте фильм: в жизни грустной девочки появляется незадачливый молодой отец — и все это режиссерский дебют британской кинематографистки. \n\nНет, это не «Солнце мое», а «Задира» — пронзительное драмеди с Харрисом Диккинсоном из «Треугольника печали»! Сняла фильм Шарлотта Риган, и он уже успел стать победителем «Сандэнса». \n\nКакой получилась эта история о взрослении и переживании горя, читайте [здесь](https://www.kinopoisk.ru/media/news/4008980/). \n\n🔥 Подписывайтесь на [**«Кинопоиск»**](https://t.me/kinopoisk) 55890.0 {'_': 'MessageReactions', 'results': [{'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤'}, 'count': 124, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👍'}, 'count': 31, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤\u200d🔥'}, 'count': 15, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👎'}, 'count': 8, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '⚡'}, 'count': 3, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🔥'}, 'count': 3, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '😱'}, 'count': 3, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🐳'}, 'count': 1, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🦄'}, 'count': 1, 'chosen_order': None}], 'min': False, 'can_see_list': False, 'recent_reactions': []} {'_': 'MessageMediaWebPage', 'webpage': {'_': 'WebPage', 'id': 5475316182972329094, 'url': 'https://www.kinopoisk.ru/media/news/4008980/', 'display_url': 'kinopoisk.ru/media/news/4008980', 'hash': 566396092, 'has_large_media': True, 'type': 'photo', 'site_name': 'Кинопоиск', 'title': '«Задира»: как «Солнце мое», только\xa0без меланхолии', 'description': '«Задира» — удивительный дебют Шарлотты Риган, чем-то похожий на «Солнце мое». В изобретательной фантазии про горе-папашу и дочь-пацанку отца играет Харрис Дикинсон из «Треугольника печали».', 'photo': {'_': 'Photo', 'id': 5858298657344959169, 'access_hash': -7508799176150459187, 'file_reference': b'\x00e\xac\xd9\x08m;v\xba\x01+\x0fN[0:\xdb\xdf\x84#\x99', 'date': datetime.datetime(2024, 1, 18, 13, 54, 36, tzinfo=datetime.timezone.utc), 'sizes': [{'_': 'PhotoStrippedSize', 'type': 'i', 'bytes': b'\x01\x15(\xaf\x1d\xb2\x80\x03t\xf5\xda)>\xc9\x03\xbb\r\xec\x08\xec\x06*\xd4n\xa5\x07#\xf1\xff\x00\xf5R\xeeQ l\x03\xef\x8f\xfe\xb5\x00QkH\xd6@\x9b\xd8\x93\xe9Ok\x18V=\xe5\xde\xa5\xba\x91H\x0e\xb8\xf48\xa8\x85\xc0e\nFH\xe4\n\x068\xe9\xd1\xec\xdc\x1d\xbag\x9a*U\xb82F~N\xa3\xd6\x8a\x04G\x19\xebN-\xb5\t\xeb\xcd\x14S\x02\xb0vRT\x1e:\xf3L\x90lT\x90\x1c1\xa2\x8a@=\x1b\xf8\x86A9\xcf4QE\x00\x7f'}, {'_': 'PhotoSize', 'type': 'm', 'w': 320, 'h': 168, 'size': 15016}, {'_': 'PhotoSize', 'type': 'x', 'w': 800, 'h': 420, 'size': 60394}, {'_': 'PhotoSizeProgressive', 'type': 'y', 'w': 1200, 'h': 630, 'sizes': [10919, 28022, 42073, 58652, 94776]}], 'dc_id': 4, 'has_stickers': False, 'video_sizes': []}, 'embed_url': None, 'embed_type': None, 'embed_width': None, 'embed_height': None, 'duration': None, 'author': None, 'document': None, 'cached_page': {'_': 'Page', 'url': 'https://www.kinopoisk.ru/media/news/4008980/', 'blocks': [{'_': 'PageBlockChannel', 'channel': {'_': 'Channel', 'id': 1054210809, 'title': 'Кинопоиск: фильмы и сериалы', 'photo': {'_': 'ChatPhoto', 'photo_id': 5425084126744136490, 'dc_id': 2, 'has_video': False, 'stripped_thumb': b'\x01\x08\x08\xcf\x88C\xe5\xae\xe2\xbb\xbd\xe8\xa2\x8a\x87\x1f2\xb9\x8f'}, 'date': datetime.datetime(2016, 5, 13, 11, 58, 50, tzinfo=datetime.timezone.utc), 'creator': False, 'left': False, 'broadcast': True, 'verified': True, 'megagroup': False, 'restricted': False, 'signatures': False, 'min': True, 'scam': False, 'has_link': True, 'has_geo': False, 'slowmode_enabled': False, 'call_active': False, 'call_not_empty': False, 'fake': False, 'gigagroup': False, 'noforwards': False, 'join_to_send': False, 'join_request': False, 'forum': False, 'stories_hidden': False, 'stories_hidden_min': True, 'stories_unavailable': True, 'access_hash': 675089040032349539, 'username': 'kinopoisk', 'restriction_reason': [], 'admin_rights': None, 'banned_rights': None, 'default_banned_rights': None, 'participants_count': None, 'usernames': [], 'stories_max_id': None, 'color': None}}, {'_': 'PageBlockTitle', 'text': {'_': 'TextPlain', 'text': '«Задира»: как «Солнце мое», только\xa0без меланхолии'}}, {'_': 'PageBlockAuthorDate', 'author': {'_': 'TextEmpty'}, 'published_date': datetime.datetime(2024, 1, 18, 0, 0, tzinfo=datetime.timezone.utc)}, {'_': 'PageBlockParagraph', 'text': {'_': 'TextConcat', 'texts': [{'_': 'TextPlain', 'text': '18\xa0января в\xa0российский прокат вышел режиссерский дебют '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Шарлотты Риган'}, 'url': 'https://www.kinopoisk.ru/name/3866796', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ' про отношения 12-летней пацанки с\xa0юным папашей. О\xa0победителе «Сандэнса» с\xa0модной звездой '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': '«Треугольника печали»'}, 'url': 'https://www.kinopoisk.ru/film/1348487/', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ' '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Харрисом Дикинсоном'}, 'url': 'https://www.kinopoisk.ru/name/3498137', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ' рассказывает Михаил Моркин.'}]}}, {'_': 'PageBlockHeader', 'text': {'_': 'TextPlain', 'text': 'О\xa0чем это'}}, {'_': 'PageBlockPhoto', 'photo_id': 5858576485894435563, 'caption': {'_': 'PageCaption', 'text': {'_': 'TextPlain', 'text': 'Харрис Дикинсон и Лола Кэмпбелл'}, 'credit': {'_': 'TextEmpty'}}, 'url': None, 'webpage_id': None}, {'_': 'PageBlockParagraph', 'text': {'_': 'TextConcat', 'texts': [{'_': 'TextPlain', 'text': 'После смерти матери 12-летняя Джорджи (впечатляющий дебют '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Лолы Кэмпбелл'}, 'url': 'https://www.kinopoisk.ru/name/6817963', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ') проводит лето одна в\xa0крохотной квартирке безликого жилмассива в\xa0Эссексе. Социальные службы уверены, что она живет с\xa0дядей по\xa0имени Уинстон Черчилль, доверчиво покупаясь на\xa0аудиосообщения а-ля «У\xa0Джорджи все хорошо», которые смышленная хитрюга просит надиктовать добродушного продавца в\xa0продуктовом. Деньги на\xa0пропитание и\xa0арендную плату она достает, промышляя кражей велосипедов с\xa0дружбаном-соседом Али (не\xa0менее убедительный дебютант '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Алин Узун'}, 'url': 'https://www.kinopoisk.ru/name/6817964', 'webpage_id': 0}, {'_': 'TextPlain', 'text': '). В\xa0общем, быт сироты более-менее налажен, осталось только пройти еще пару стадий принятия горя. И\xa0тут в\xa0жизнь Джорджи влезает крашеный блондин Джейсон (Харрис Дикинсон из\xa0«Треугольника печали» и\xa0'}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': '«Убийства на\xa0краю света»'}, 'url': 'https://www.kinopoisk.ru/film/4672773/', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ')\xa0— ее\xa0молодой папаша, которого она никогда в\xa0жизни не\xa0видела. Теперь они будут жить вместе.'}]}}, {'_': 'PageBlockHeader', 'text': {'_': 'TextPlain', 'text': 'Кто это снял'}}, {'_': 'PageBlockPhoto', 'photo_id': 5858225634310992646, 'caption': {'_': 'PageCaption', 'text': {'_': 'TextPlain', 'text': 'Харрис Дикинсон'}, 'credit': {'_': 'TextEmpty'}}, 'url': None, 'webpage_id': None}, {'_': 'PageBlockParagraph', 'text': {'_': 'TextConcat', 'texts': [{'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': '«Задира»'}, 'url': 'https://www.kinopoisk.ru/film/4910100/', 'webpage_id': 0}, {'_': 'TextPlain', 'text': '\xa0— фестивальный дебют молодой британской режиссерки Шарлотты Риган. Фильм об\xa0отношениях не\xa0по\xa0годам смышленой девочки с\xa0молодым отцом-разгильдяем напоминает описание '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': '«Солнца моего»'}, 'url': 'https://www.kinopoisk.ru/film/4948281/', 'webpage_id': 0}, {'_': 'TextPlain', 'text': '. Только дебют Шарлотты Уэллс стартовал в\xa0Каннах, а\xa0фильм Риган прогремел год назад на\xa0«Сандэнсе», где даже получил приз большого жюри. Но\xa0более важное различие между «Задирой» и\xa0«Солнцем»\xa0— в\xa0интонации. «Солнце мое» было грустной и\xa0пронзительной автобиографией, а\xa0«Задира»\xa0— бойкая и\xa0стремительная (всего 80\xa0минут!) фантазия (впрочем, тоже отчасти автобиографическая).'}]}}, {'_': 'PageBlockParagraph', 'text': {'_': 'TextConcat', 'texts': [{'_': 'TextPlain', 'text': 'Оператором фильма выступила '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Молли Мэннинг Уокер'}, 'url': 'https://www.kinopoisk.ru/name/2819555', 'webpage_id': 0}, {'_': 'TextPlain', 'text': '\xa0— еще одна молодая кинематографистка, чей режиссерский дебют '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': '«Как заниматься сексом»'}, 'url': 'https://www.kinopoisk.ru/film/5305440/', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ' про потерю девственности победил\xa0в прошлом году в\xa0каннском конкурсе «Особый взгляд».'}]}}, {'_': 'PageBlockParagraph', 'text': {'_': 'TextConcat', 'texts': [{'_': 'TextPlain', 'text': 'Харрис Дикинсон продолжает укреплять свое амплуа добродушного, растерянного и\xa0ненадежного простака. Мало кто из\xa0современных молодых актеров умеет так обаятельно изображать оболтусов. 27-летний британец умудряется удивительным образом совмещать в\xa0себе черты сразу всех торчков из\xa0«'}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'На\xa0игле»:'}, 'url': 'https://www.kinopoisk.ru/film/515/', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ' привлекательность '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Юэна Макгрегора'}, 'url': 'https://www.kinopoisk.ru/name/7590', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ', неуклюжесть '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Юэна Бремнера'}, 'url': 'https://www.kinopoisk.ru/name/31970', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ', дерзость '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Джонни Ли\xa0Миллера'}, 'url': 'https://www.kinopoisk.ru/name/14843', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ' и\xa0трагичность '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Кевина МакКидда'}, 'url': 'https://www.kinopoisk.ru/name/38714', 'webpage_id': 0}, {'_': 'TextPlain', 'text': '. Однако настоящее открытие фильма\xa0— живая и\xa0очаровательная Лола Кэмпбелл, чья пробивная героиня заставит вас купить мешковатую футболку клуба «Вест Хэм Юнайтед» и\xa0научит произносить слово «proper» (четкий) с\xa0тяжелейшим акцентом жителей Северного Лондона.'}]}}, {'_': 'PageBlockHeader', 'text': {'_': 'TextPlain', 'text': 'Как это снято'}}, {'_': 'PageBlockPhoto', 'photo_id': 5858565473598288587, 'caption': {'_': 'PageCaption', 'text': {'_': 'TextPlain', 'text': 'Алин Узун и Лола Кэмпбелл'}, 'credit': {'_': 'TextEmpty'}}, 'url': None, 'webpage_id': None}, {'_': 'PageBlockParagraph', 'text': {'_': 'TextConcat', 'texts': [{'_': 'TextPlain', 'text': 'Несмотря на\xa0сюжет и\xa0локации, здесь почти не\xa0пахнет неореализмом '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': '«Похитителей велосипедов»'}, 'url': 'https://www.kinopoisk.ru/film/432/', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ', «драмой кухонной мойки» и\xa0реалистичными жизнеописаниями рабочего класса под авторством '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Кена Лоуча'}, 'url': 'https://www.kinopoisk.ru/name/38774', 'webpage_id': 0}, {'_': 'TextPlain', 'text': '. Если это реализм, но\xa0скорее уж\xa0магический, а\xa0не\xa0социальный. Риган находит место для говорящих пауков Наполеона и\xa0Александра Великого (они общаются с\xa0помощью речевых пузырей из\xa0комиксов) и для чудо-башни из\xa0всякого мусора и\xa0металлолома, которую Джорджи возводит прямо у\xa0себя дома. У\xa0девчонки вообще богатая фантазия: своего отца она представляет то\xa0в\xa0образе вампира, то как гангстера, то как заключенного. Недолго думая, Джейсон тоже начинает красть велосипеды, образуя с\xa0дочкой дурашливый криминальный дуэт, напоминающий о\xa0'}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': '«Бумажной луне»'}, 'url': 'https://www.kinopoisk.ru/film/5033/', 'webpage_id': 0}, {'_': 'TextPlain', 'text': ' '}, {'_': 'TextUrl', 'text': {'_': 'TextPlain', 'text': 'Питера Богдановича'}, 'url': 'https://www.kinopoisk.ru/name/3010', 'webpage_id': 0}, {'_': 'TextPlain', 'text': '.'}]}}, {'_': 'PageBlockHeader', 'text': {'_': 'TextPlain', 'text': 'Вердикт'}}, {'_': 'PageBlockPhoto', 'photo_id': 5858252001115222684, 'caption': {'_': 'PageCaption', 'text': {'_': 'TextEmpty'}, 'credit': {'_': 'TextEmpty'}}, 'url': None, 'webpage_id': None}, {'_': 'PageBlockParagraph', 'text': {'_': 'TextPlain', 'text': '«Задира» с\xa0легкой интонацией рассказывает не\xa0только о\xa0переживании смерти и\xa0быстром взрослении. Трудный подросток Джорджи уверена, что уже прошла стадии отрицания, гнева и\xa0торга, а\xa0значит, депрессия и\xa0принятие не\xa0займут много времени. Пока она, правда, все еще боится передвигать мамины подушки и\xa0смотреть без нее Netflix. При этом не\xa0очень понятно, кто здесь взрослеет больше — хулиганка Джорджи или незадачливый Джейсон, который не\xa0умеет варить кашу и\xa0включать стиральную машину.'}}, {'_': 'PageBlockParagraph', 'text': {'_': 'TextPlain', 'text': 'Через душевные разговоры, добрую иронию и\xa0неловкие приключения дисфункциональная семья воссоединяется. Оказывается, сила воображения и\xa0постепенное доверие могут излечить суровую драму жизни, которую кинематографисты привыкли скорбно оплакивать.'}}, {'_': 'PageBlockDivider'}, {'_': 'PageBlockParagraph', 'text': {'_': 'TextConcat', 'texts': [{'_': 'TextBold', 'text': {'_': 'TextPlain', 'text': 'Автор:'}}, {'_': 'TextPlain', 'text': ' Михаил Моркин'}]}}], 'photos': [{'_': 'Photo', 'id': 5858576485894435563, 'access_hash': -6347752733489840788, 'file_reference': b'\x00e\xac\xd9\x08v\xcel\xdb"\xc1s\xa2)b\xcd\xaf\xf1^\xd51', 'date': datetime.datetime(2024, 1, 18, 13, 54, 36, tzinfo=datetime.timezone.utc), 'sizes': [{'_': 'PhotoStrippedSize', 'type': 'i', 'bytes': b'\x01\x17(\x9e\x02\xb2\xc5\xbc\xe4`\xe0\xf3P\x19\x8f\xda\xbc\xb5\xe53\x8a\xae\x8c\xc0\xcb\x18<\x81\x91\xf5\xa8\xed\xeeDs\xa4\x8e\xb9\x1d\xff\x00*\x9b\x01j\xe5\x00\x99\x87\xd2\xac\xc5\x101)$\x8e*\x9c\xb7),\xac\xc07>\xd5\r\xcc\xfb\xf6\x14c\x85\x18\xa2\xcc\r\x1b\x85\x10\xc2\xee\t\xe0w\xa2\xa1\x9e\xe5$\xd3J\x97\x1b\xc8\x1f\x8f4P\x90\x15 B\x08vbX\xd4R\x08\xc6p\xa7\xf3\xa2\x8a`\x86\x996\xe0(\xc5 _\x97&\x8a)\xa0cs\xf2\xe3\xb0\xa2\x8a(\x19'}, {'_': 'PhotoSize', 'type': 'm', 'w': 320, 'h': 180, 'size': 13787}, {'_': 'PhotoSize', 'type': 'x', 'w': 800, 'h': 450, 'size': 56659}, {'_': 'PhotoSizeProgressive', 'type': 'y', 'w': 960, 'h': 540, 'sizes': [7915, 18420, 23943, 35293, 71746]}], 'dc_id': 4, 'has_stickers': False, 'video_sizes': []}, {'_': 'Photo', 'id': 5858225634310992646, 'access_hash': 4063387891663174401, 'file_reference': b'\x00e\xac\xd9\x08\x07\xa5\xb4c\r\xdd\xb0\xf3\x9f7\xa7\xd4d\x19\xce\xf8', 'date': datetime.datetime(2024, 1, 18, 13, 54, 36, tzinfo=datetime.timezone.utc), 'sizes': [{'_': 'PhotoStrippedSize', 'type': 'i', 'bytes': b"\x01\x17(\xa9\x14\x9d;c4\x84\x16'\xf9Th8\xfd*E<\x8a\x8b\x01m-\xa3\xc6\x15\xf2\xde\xbd\xaa\xab|\x8eT\xf5\x07\x15,o\xb5\xcf$\x01\xcdW'$\x9ae\x0f\xdepGj)\x94S\x11u`\x0b\x04K\x93\x97\xe4\xfd:\xd4\xa8\x91Ko\x98\x97\nx\r\xde\x8a(\x10\x90Z\x94\xdc\xccA\xf4\x15\x1d\xc4\x91\xa4\x8e\xaa\xa42\x8c\xe3\xb1\xff\x009\xa2\x8a\x10=J\xc6\xe4\x0c|\x9f\xad\x14QT\x89g"}, {'_': 'PhotoSize', 'type': 'm', 'w': 320, 'h': 180, 'size': 14401}, {'_': 'PhotoSize', 'type': 'x', 'w': 800, 'h': 450, 'size': 59395}, {'_': 'PhotoSizeProgressive', 'type': 'y', 'w': 960, 'h': 540, 'sizes': [8609, 21493, 26041, 37778, 74823]}], 'dc_id': 4, 'has_stickers': False, 'video_sizes': []}, {'_': 'Photo', 'id': 5858565473598288587, 'access_hash': -1911332357782444587, 'file_reference': b'\x00e\xac\xd9\x08\xf1o\xefc|\xb4Z\xa7\x9e\xad\xf1\xea\xb0\xda\xfe^', 'date': datetime.datetime(2024, 1, 18, 13, 54, 36, tzinfo=datetime.timezone.utc), 'sizes': [{'_': 'PhotoStrippedSize', 'type': 'i', 'bytes': b'\x01\x17(\xa9\r\xc7\xcc\xfd\x81;\xba\xd4\xc2d\x92@\x10\x9f\xc6\xaa\x04\xc3\xb7\xf7NE,M\xe5H\x19O#\xbd!\xa6^r|\xc1\x9c\x0e\xbf\xca\xa5\x8e^\x99`\x07\xbdT2\xbc\x87.s\xf8S]\xb2\x84qH.[i\x13\xcc\xc0l\x93\xd2\x8a\xce\x1b\x82\xee\xcfN\x9e\xf4P\x03\x1c\xe4\x92:\nL\xd1EZ!\x93\x05m\x81\x89\xe2\xa2\xdf\xb4\xe3\x19\x19\xa2\x8aE E\xdf \x1d\x01\xa2\x8a(\x03'}, {'_': 'PhotoSize', 'type': 'm', 'w': 320, 'h': 180, 'size': 14781}, {'_': 'PhotoSize', 'type': 'x', 'w': 800, 'h': 450, 'size': 59457}, {'_': 'PhotoSizeProgressive', 'type': 'y', 'w': 960, 'h': 540, 'sizes': [8663, 21364, 26389, 37691, 75170]}], 'dc_id': 4, 'has_stickers': False, 'video_sizes': []}, {'_': 'Photo', 'id': 5858252001115222684, 'access_hash': -7880929634662159804, 'file_reference': b'\x00e\xac\xd9\x08\xd2\xca\x0c\x99\x93\xedqp\xd4>?\xfb\xc8\x96\r\x97', 'date': datetime.datetime(2024, 1, 18, 13, 54, 56, tzinfo=datetime.timezone.utc), 'sizes': [{'_': 'PhotoStrippedSize', 'type': 'i', 'bytes': b'\x01\x17(\xb3\x05\xdcs\xf0\x0e\x1b\xd0\xd3\xd6dwdV\x05\x87Z\xc3\x8f\xcc\x8eM\xc1[\xf0\xa9\xe3\x90\xc6\xdb\xd5\n\x9e\x99\xcdM\xc5cc4f\xb2\x1a\xe9\xf9$\xc9\xeb\xc1\xa2)\xe6BJd\x86\xe7\x9ei\xdc\rcET[\xbf\x90oF\xdd\xdf\x02\x8a.\x80\x88?\xa0\xc5)u\x1cu\xf5\xa2\x8a\xc4\xa1Cn\x1ftc\xbd\n\xdb\x06\xdd\xa2\x8a(\x18\xa1\xd7n\x08\xe6\x8a(\xa0\x0f'}, {'_': 'PhotoSize', 'type': 'm', 'w': 320, 'h': 180, 'size': 15656}, {'_': 'PhotoSize', 'type': 'x', 'w': 800, 'h': 450, 'size': 72033}, {'_': 'PhotoSizeProgressive', 'type': 'y', 'w': 960, 'h': 540, 'sizes': [7400, 19788, 31754, 48143, 88968]}], 'dc_id': 4, 'has_stickers': False, 'video_sizes': []}], 'documents': [], 'part': False, 'rtl': False, 'v2': False, 'views': None}, 'attributes': []}, 'force_large_media': False, 'force_small_media': False, 'manual': True, 'safe': False} 148.0 {'_': 'MessageReplies', 'replies': 12, 'replies_pts': 1171733, 'comments': True, 'recent_repliers': [{'_': 'PeerUser', 'user_id': 1220823926}, {'_': 'PeerUser', 'user_id': 2085283023}, {'_': 'PeerUser', 'user_id': 6754389370}], 'channel_id': 1244684646, 'max_id': 730380, 'read_max_id': None} 189 12 MessageMediaWebPage
7 https://t.me/kinopoisk 37118 2024-01-20 10:48:48+00:00 Правда или фейк? 🧐\n\n#ДежурныйПоКинопоиску Сергей Сироткин пытается отличить настоящие новости от выдуманных.\n\nПолный [выпуск нашего шоу](https://youtu.be/KOhDZReSQrs?si=wL9bwB_vJthBC6qp) — уже на YouTube-канале «Кинопоиск Экстра»!\n\n🔥 Подписывайтесь на [**«Кинопоиск»**](https://t.me/kinopoisk) 60348.0 {'_': 'MessageReactions', 'results': [{'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤'}, 'count': 122, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👍'}, 'count': 50, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '😁'}, 'count': 15, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🤩'}, 'count': 8, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👎'}, 'count': 5, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🔥'}, 'count': 4, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🍓'}, 'count': 3, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🐳'}, 'count': 2, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🍾'}, 'count': 1, 'chosen_order': None}], 'min': False, 'can_see_list': False, 'recent_reactions': []} {'_': 'MessageMediaDocument', 'nopremium': False, 'spoiler': False, 'document': {'_': 'Document', 'id': 5287757868462785694, 'access_hash': 2933086641076259676, 'file_reference': b'\x02>\xd5\xfa\xf9\x00\x00\x90\xfee\xac\xd9\x08]w\xa7\xb8Vn$\x8a\xf8\xed9\x84-Co\xd6', 'date': datetime.datetime(2024, 1, 20, 10, 48, 48, tzinfo=datetime.timezone.utc), 'mime_type': 'video/mp4', 'size': 128736695, 'dc_id': 2, 'attributes': [{'_': 'DocumentAttributeVideo', 'duration': 59.8, 'w': 1080, 'h': 1920, 'round_message': False, 'supports_streaming': True, 'nosound': False, 'preload_prefix_size': None}, {'_': 'DocumentAttributeFilename', 'file_name': 'dezh_sirotkin_reels_04.mp4'}], 'thumbs': [{'_': 'PhotoStrippedSize', 'type': 'i', 'bytes': b'\x01(\x16\xa5\x8ai\x15&)\x08\xac\xeeob,QN"\x8a\xab\x93bZCI\xc9<PCz\x1f\xca\xa0\xd0i\xa2\x83EPXP\xc4t&\x8d\xed\xfd\xe3\xf9\xd1E\x16\x00$\x9e\xa4\x9a(\xa2\x80?'}, {'_': 'PhotoSize', 'type': 'm', 'w': 180, 'h': 320, 'size': 4447}], 'video_thumbs': []}, 'alt_document': None, 'ttl_seconds': None} 27.0 {'_': 'MessageReplies', 'replies': 3, 'replies_pts': 1171733, 'comments': True, 'recent_repliers': [{'_': 'PeerUser', 'user_id': 6555146753}, {'_': 'PeerUser', 'user_id': 1448648552}, {'_': 'PeerUser', 'user_id': 6224725917}], 'channel_id': 1244684646, 'max_id': 730147, 'read_max_id': None} 210 3 MessageMediaDocument

В нашем распоряжении датафрйем channel_posts с 13 колонками и 23326 строками.
Сведения о постах в телеграм канале Кинопоиска

  • channel - неинформативная колонка - название канала
  • id - идентификатор поста Соответствует post_id в файле с комментариями к постам
  • date - дата и время публикации поста в формате CTE (+2 часа для получения московского времени)
  • text - текст поста
  • views - количество просмотров поста
  • reactions - словарь с реакциями (эмоджи/смайлы) на пост Дает информацию о типе смайла и его количестве
  • with_media - дает представление о прикрепленном к посту документе Как правило MessageMediaPhoto - фото MessageMediaDocument - видео MessageMediaWebPage - ссылка
  • forwarded - сколько раз пересылался пост
  • replies - словарь с количеством комментариев к посту
  • reactions_count - количество реакций/эмоджи на пост Столбец получен из столбца reactions путем суммирование количества всех реакций
  • comments - количество комментариев к посту Столбец получен на основе Replies
  • type_attachement - вид прикрепленного к посту документа Получен из словаря в With media
In [ ]:
print(comments.info())
display(comments.head())
<class 'pandas.core.frame.DataFrame'>
Int64Index: 139522 entries, 0 to 17924
Data columns (total 4 columns):
 #   Column        Non-Null Count   Dtype 
---  ------        --------------   ----- 
 0   Unnamed: 0    139522 non-null  int64 
 1   post_id       139522 non-null  int64 
 2   date_comment  139522 non-null  object
 3   text_comment  132192 non-null  object
dtypes: int64(2), object(2)
memory usage: 5.3+ MB
None
Unnamed: 0 post_id date_comment text_comment
0 0 37068 2024-01-18 09:34:31+00:00 От бесстыжих к медведю - так это скорее не путь, а спуск
1 1 37068 2024-01-18 09:35:00+00:00 учился орать FUCK!
2 2 37068 2024-01-18 09:35:11+00:00 Верните бестыжих
3 3 37068 2024-01-18 09:36:34+00:00 От липа из бесстыжих до рекламы кельвин кляйн
4 4 37068 2024-01-18 09:37:41+00:00 этот навык был освоен еще в "бесстыжих"🧡

Датафрейм comments с 5 колонками и 139522 строками.
Текстовые комментарии к постам

  • post_id - идентификатор поста, к которому был написан комментарий Соответствует ID в файле subscribers_general
  • date_comment - дата публикации комментария в формате CTE
  • text_comment - текстовое содержание комментария
In [ ]:
print(subscribers_general.info())
display(subscribers_general.head())
<class 'pandas.core.frame.DataFrame'>
Int64Index: 520 entries, 0 to 519
Data columns (total 3 columns):
 #   Column       Non-Null Count  Dtype 
---  ------       --------------  ----- 
 0   date         520 non-null    object
 1   subscribers  520 non-null    int64 
 2   changes      520 non-null    int64 
dtypes: int64(2), object(1)
memory usage: 16.2+ KB
None
date subscribers changes
0 18.01.24 551378 415
1 17.01.24 550963 2061
2 16.01.24 548902 2043
3 15.01.24 546859 2539
4 14.01.24 544320 2021

Датафрейм subscribers_general 4 колонки и 520 строк.
Общая информация о подписчиках канала в разбивке по дням

  • date - дата
  • subscribers - суммарное количество подписок на эту дату
  • changes - разница в количестве подписчиков между текущей и предыдущей датами
In [ ]:
print(subscribers_detailed.info())
display(subscribers_detailed.head())
<class 'pandas.core.frame.DataFrame'>
Int64Index: 12282 entries, 0 to 12281
Data columns (total 4 columns):
 #   Column        Non-Null Count  Dtype 
---  ------        --------------  ----- 
 0   date          12282 non-null  object
 1   time          12282 non-null  object
 2   subscribers   12282 non-null  int64 
 3   unsubscribed  12282 non-null  int64 
dtypes: int64(2), object(2)
memory usage: 479.8+ KB
None
date time subscribers unsubscribed
0 Чт, 18 Jan 11:29 204 -300
1 Чт, 18 Jan 11:00 373 -366
2 Чт, 18 Jan 10:00 333 -150
3 Чт, 18 Jan 09:00 211 -133
4 Чт, 18 Jan 08:00 152 -90

Датафрейм subscribers_detailed 5 колонок и 12282 строки.
Представлена информация о подписчиках канала и отписках

  • date - дата
  • time - время
  • subscribers - количество подписавшихся
  • unsubscribed - количество отписок
In [ ]:
print(reposts_and_mentions.info())
display(reposts_and_mentions.head())
<class 'pandas.core.frame.DataFrame'>
Int64Index: 2306 entries, 0 to 2305
Data columns (total 4 columns):
 #   Column              Non-Null Count  Dtype 
---  ------              --------------  ----- 
 0   channel             2306 non-null   object
 1   number_subscribers  2306 non-null   int64 
 2   action              2306 non-null   object
 3   date                2306 non-null   object
dtypes: int64(1), object(3)
memory usage: 90.1+ KB
None
channel number_subscribers action date
0 Обсуждаем фильмы. Поиском кино не занимаемся 1358 репостнул запись 19 Jan, 10:02
1 🎬 КиноДед 1409 репостнул запись 18 Jan, 23:33
2 супер8 22276 упомянул канал 18 Jan, 22:48
3 Стримми 13152 упомянул канал 18 Jan, 22:35
4 Обсуждаем фильмы. Поиском кино не занимаемся 1359 упомянул канал 18 Jan, 21:02

Датафрейм reposts_and_mentions - 5 колонок, 2306 строк.
Данные за 3 месяца о телеграм каналах и группах, которые репостили посты Кинопоиска, либо упоминали Кинопоиск в своих постах.

  • channel - канал или группа, который сделал репост/упоминание Кинопоиска
  • number_subscribers - количество подписчиков этого канала
  • action - тип действия - репост или упоминание
  • date- когда было произведено действие.

Предобработка данных¶

Удаляем ненужные для анализа столбцы¶

В датафрейме comments у нас есть столбец Unnamed который появился, возможно, при создании датасета, и дублируют индекс. Удалим его.

In [ ]:
comments = comments.drop(columns=['Unnamed: 0'])
print(comments.columns)
Index(['post_id', 'date_comment', 'text_comment'], dtype='object')

Мы удалили лишние колонки.

Проверка на пропуски¶

Посмотрим на количество пропусков в процентном соотношении в каждом датафрейме.

In [ ]:
for i in (channel_posts, comments, subscribers_general, subscribers_detailed, reposts_and_mentions):
  display(pd.DataFrame(round(i.isna().mean()*100,1)).style.background_gradient('coolwarm'))
  0
channel 0.000000
id 0.000000
date 0.000000
text 0.000000
views 0.000000
reactions 59.200000
with_media 3.100000
forwarded 0.000000
replies 47.600000
reactions_count 0.000000
comments 0.000000
type_attachment 3.100000
  0
post_id 0.000000
date_comment 0.000000
text_comment 5.300000
  0
date 0.000000
subscribers 0.000000
changes 0.000000
  0
date 0.000000
time 0.000000
subscribers 0.000000
unsubscribed 0.000000
  0
channel 0.000000
number_subscribers 0.000000
action 0.000000
date 0.000000

Мы видим, что в датафреймах subscribers_general, subscribers_detailed, reposts_and_mentions пропусков нет.
В датафрейме comments в колонке text_comment 5,3% пропусков, возможно, так тображаются комментарии со стикерами, эмодзи или гифками.
В датафрейме channel_posts в колонке reactions 53% строки с пропусками и в колонке replies 47,6% строк с пропусками. Возможно, реакции и комментарии были подключены позже, чем начало датафрейма.
Из новостей обновлений Telegram мы знаем, что комментарии были включены 30 сентября 2020 года, а реакции 30 декабря 2021 года. Если данные у нас есть раньше этих дат, то пропуски логичны.

Проверка на явные дубли¶

Проверим датафреймы на явные дубли.

In [ ]:
for i in (channel_posts, comments, subscribers_general, subscribers_detailed, reposts_and_mentions):
  print(f'Явных дублей в датафреме {i.duplicated().sum()}')
  print(f'Доля дублей от всего датафрейма {(i.duplicated().sum()*100/len(i)).round(2)} %')
Явных дублей в датафреме 0
Доля дублей от всего датафрейма 0.0 %
Явных дублей в датафреме 620
Доля дублей от всего датафрейма 0.44 %
Явных дублей в датафреме 0
Доля дублей от всего датафрейма 0.0 %
Явных дублей в датафреме 0
Доля дублей от всего датафрейма 0.0 %
Явных дублей в датафреме 85
Доля дублей от всего датафрейма 3.69 %

Мы видим, что в датафрейме comments есть 620 явныхдублей, они могли возникнуть при сборе информации, т.к. не могли быть написаны несколько одинковых комментариев в одну секунду, и это 0,44% от всего датафрейма, их можно удалить. А в датафрейме reposts_and_mentions есть 85 явных дублей, это может говорить, что один канал даважды за одну минуту упомянул кинопоиск, или сделал репост, что возможно, эти дубли мы оставим.

In [ ]:
comments = comments.drop_duplicates()
In [ ]:
print(f'Явных дублей в датафреме {comments.duplicated().sum()}')
print(f'Доля дублей от всего датафрейма {(comments.duplicated().sum()*100/len(comments)).round(2)} %')
Явных дублей в датафреме 0
Доля дублей от всего датафрейма 0.0 %

Мы изюавились от явных дублей в датафрейме comments.

Проверка на неявные явные дубли¶

Проверка на неявные явные дубли в датафрейме channel_posts¶

Проверим, есть ли в колонке channel каналы, кроме кинопоиска.

In [ ]:
print('Каналы в колонке channel', channel_posts['channel'].unique())
Каналы в колонке channel ['https://t.me/kinopoisk']

Мы видим, что в этой колонке только один канал - кинопоиска, это не пригодится нам в дальнейшем анализе, поэтому, чтобы облегчить немного датафрейм, мы удалим эту колонку. Также удалим колонку with_media, она содержит информацию о приложенных медиа, в данном анализе нам пригодится только тип медиа, который указан в type_attachment

In [ ]:
channel_posts = channel_posts.drop(columns=['channel', 'with_media'])
print(channel_posts.columns)
Index(['id', 'date', 'text', 'views', 'reactions', 'forwarded', 'replies',
       'reactions_count', 'comments', 'type_attachment'],
      dtype='object')

Колонка удалена.

Проверим, нет ли дублей в колонке id, т.е. не попал ли один пост дважды к нам в анализ.

In [ ]:
temp = channel_posts.groupby(by='id').agg({'date':'count'})
print('Повторяющихся id в датафрейме', len(temp.query('date > 1')))
Повторяющихся id в датафрейме 0

Повторяющихся постов нет.

Проверка на неявные явные дубли в датафрейме subscribers_general¶

Проверим, не попала ли одна и также дата дважды в датафрейм subscribers_general

In [ ]:
temp = subscribers_general.groupby(by='date').agg({'subscribers':'count'})
print('Повторяющихся дат в датафрейме', len(temp.query('subscribers > 1')))
Повторяющихся дат в датафрейме 0

Повторяющихся дат нет.

Изменение типа данных¶

Изменение типа данных в датафрейме channel_posts¶

Для удобства работы изменим тип данных в колонке со временем и даты в формат datetime64, сохранив его в колонку date_time и поменяем часовой пояс на Москву, а также добавим колонку с датой потста - post_date

In [ ]:
channel_posts['date_time']= pd.to_datetime(channel_posts['date'], format="%Y-%m-%d %H:%M:%S%z", utc=True).dt.tz_convert('Europe/Moscow')
channel_posts['post_date'] = pd.to_datetime(channel_posts['date_time']).dt.date
In [ ]:
channel_posts['date_time'].head()
Out[ ]:
0   2024-01-21 11:02:21+03:00
4   2024-01-20 21:01:00+03:00
5   2024-01-20 19:01:16+03:00
6   2024-01-20 17:20:04+03:00
7   2024-01-20 13:48:48+03:00
Name: date_time, dtype: datetime64[ns, Europe/Moscow]
In [ ]:
print('Формат данных в date_time', channel_posts['date_time'].dtype)
print('Формат данных в post_date', channel_posts['post_date'].dtype)
print(f'В датафрейме данные с {channel_posts.post_date.min()} по {channel_posts.post_date.max()}')
Формат данных в date_time datetime64[ns, Europe/Moscow]
Формат данных в post_date object
В датафрейме данные с 2016-12-19 по 2024-01-21

Тип данных изменен, также мы видим, что в датафрейме посты с 19 декабря 2016 года (даты создания канала) по 21 января 2024 года (даты парсинга). Это подтверждает наше прошлое предположение, почему не у всех постов есть комментарии и реакции - тогда в телеграме еще не было таких функций.

Изменение типа данных в датафрейме comments¶

Для удобства работы изменим тип данных в колонке со временем и даты в формат datetime64, сохранив его в колонку comment_time.

In [ ]:
comments['comment_time']= pd.to_datetime(comments['date_comment'], format="%Y-%m-%d %H:%M:%S%z", utc=True)
print('Формат данных в comment_time', comments['comment_time'].dtype)
print(f'В датафрейме данные с {pd.to_datetime(comments.comment_time).dt.date.min()} по {pd.to_datetime(comments.comment_time).dt.date.max()}')
Формат данных в comment_time datetime64[ns, UTC]
В датафрейме данные с 2022-09-22 по 2024-01-19

Тип данных изменен.
В датафрейме комментарии с 22 сентября 2022 года по 19 января 2024 года.

Изменение типа данных в датафрейме reposts_and_mentions¶

Для удобства работы изменим тип данных в колонке со временем и даты в формат datetime64, сохранив его в колонку action_date.

In [ ]:
print(reposts_and_mentions.date.head(2))
print(reposts_and_mentions.date.tail(2))
0    19 Jan, 10:02
1    18 Jan, 23:33
Name: date, dtype: object
2304    22 Oct 2023, 13:41
2305    22 Oct 2023, 13:08
Name: date, dtype: object

Мы видим, что в датафрейме есть данные от 2023 года, с указанным годом, а также данные 2024 года, без указания года.

In [ ]:
#приведем данные в колонке с датами в формат строки
reposts_and_mentions['date_string'] = reposts_and_mentions['date'].astype('str')
#найдем строки с указанным годом
mask = reposts_and_mentions['date_string'].str.contains('2023')
#найдпем первую строку с годом и сохраним ее индекс
date_with_year_index = reposts_and_mentions[mask].head(1).index
#создадим колонку action_date и запишем строки без указания года в формате DateTime6D, подставив 2024 год
reposts_and_mentions['action_date'] = reposts_and_mentions['date_string']
reposts_and_mentions['action_date'].iloc[:date_with_year_index[0]] = pd.to_datetime(reposts_and_mentions.iloc[:date_with_year_index[0], 3],
                                                                       format='%d %b, %H:%M').dt.strftime('2024-%m-%d %H:%M')
#в колонку action_date запишем строки с годом в формате DateTime64
reposts_and_mentions['action_date'].iloc[date_with_year_index[0]:] = pd.to_datetime(reposts_and_mentions.iloc[date_with_year_index[0]:, 3],
                                                                       format='%d %b %Y, %H:%M')
#удалим вспомогательную колонку date_string
reposts_and_mentions = reposts_and_mentions.drop(columns=['date_string'])
#приведем все даты к единому формату
reposts_and_mentions['action_date'] = pd.to_datetime(reposts_and_mentions['action_date'], format='%Y-%m-%d %H:%M')
In [ ]:
print('Формат данных в action_date', reposts_and_mentions['action_date'].dtype)
print(f'В датафрейме данные с {pd.to_datetime(reposts_and_mentions.action_date).dt.date.min()} \
  по {pd.to_datetime(reposts_and_mentions.action_date).dt.date.max()}')
Формат данных в action_date datetime64[ns]
В датафрейме данные с 2023-10-22   по 2024-01-19

Форматирование данных прошло успешно.
В датафрейме данные с 22 октября 2023 года по 19 января 2024 года.

Изменение типа данных в датафрейме subscribers_general¶

Для удобства работы изменим тип данных в колонке с датой в формат даты.

In [ ]:
subscribers_general['date_format'] = pd.to_datetime(subscribers_general['date'], format='%d.%m.%y').dt.date
#отсортируем датафрейм по дате
subscribers_general = subscribers_general.sort_values(by='date_format')

print('Формат данных в date', subscribers_general['date_format'].dtype)
print(f'В датафрейме данные с {subscribers_general.date_format.min()} по {subscribers_general.date_format.max()}')
Формат данных в date object
В датафрейме данные с 2022-08-17 по 2024-01-18

Изменили формат данных.
В датафрйеме данные с 17 августа 2022 года по 18 января 2024 года.

Изменение типа данных в датафрейме subscribers_detailed¶

Для удобства работы объединим колонки с датой и временем в одну, и изменим тип данных в формат datetime64.

In [ ]:
print(subscribers_detailed.date.head(2))
print(subscribers_detailed.date.tail(2))
0    Чт, 18 Jan
1    Чт, 18 Jan
Name: date, dtype: object
12280    Ср, 17 Aug 2022
12281    Ср, 17 Aug 2022
Name: date, dtype: object

Как и в случае с reposts_and_mentions у данных из 2024 года отсутствует год.

In [ ]:
#приведем данные в колонке с датами в формат строки
subscribers_detailed['date_string'] = subscribers_detailed['date'].str.split(n=1).str[1]
#объединим колонки с датой и временем
subscribers_detailed['date_string'] = subscribers_detailed['date_string']+ ' ' + subscribers_detailed['time']

#найдем строки с указанным годом
mask = subscribers_detailed['date_string'].str.contains('2023')

#найдпем первую строку с годом и сохраним ее индекс
date_with_year_index = subscribers_detailed[mask].head(1).index


#создадим колонку date_time и запишем строки без указания года в формате DateTime6D, подставив 2024 год
subscribers_detailed['date_time'] = subscribers_detailed['date_string']
subscribers_detailed['date_time'].iloc[:date_with_year_index[0]] = pd.to_datetime(subscribers_detailed.iloc[:date_with_year_index[0], 4],
                                                                       format='%d %b %H:%M').dt.strftime('2024-%m-%d %H:%M')
#в колонку date_time запишем строки с годом в формате DateTime64
subscribers_detailed['date_time'].iloc[date_with_year_index[0]:] = pd.to_datetime(subscribers_detailed.iloc[date_with_year_index[0]:, 4],
                                                                       format='%d %b %Y %H:%M')
#удалим вспомогательную колонку date_string
subscribers_detailed = subscribers_detailed.drop(columns=['date_string'])
#приведем все даты к единому формату
subscribers_detailed['date_time'] = pd.to_datetime(subscribers_detailed['date_time'], format='%Y-%m-%d %H:%M')
In [ ]:
print('Формат данных в date', subscribers_detailed['date_time'].dtype)
print(f'В датафрейме данные с {pd.to_datetime(subscribers_detailed.date_time).dt.date.min()}\
  по {pd.to_datetime(subscribers_detailed.date_time).dt.date.max()}')
Формат данных в date datetime64[ns]
В датафрейме данные с 2022-08-17  по 2024-01-18

Изменили формат данных. В датафрйеме данные с 17 августа 2022 года по 18 января 2024 года.

Вывод
Мы загрузили и подготовили данные к анализу.

  • Загрузили необходимые для работы библиотеки.
  • Загрузили датасеты в датафреймы:
    • channel_posts с 13 колонками и 23326 строками,
    • comments с 5 колонками и 139522 строками,
    • subscribers_general с 4 колонками и 520 строками,
    • subscribers_detailed с 5 колоноками и 12282 строками,
    • reposts_and_mentions с 5 колоноками и 2306 строками.
  • Удалили лишние для анализа столбцы.
  • Проверили датафреймы на пропуски.
  • Проверили датафреймы на явные дубли, удалили явные дубли в датафрейме comments.
  • Проверили датафреймы на неявные дубли.
  • Привели даты в датафреймах к формату данных даты, данные в датафреймах:
    • channel_posts с 19 декабря 2016 года по 21 января 2024 года,
    • comments с 22 сентября 2022 года по 19 января 2024 года,
    • reposts_and_mentions с 22 октября 2023 года по 19 января 2024 года,
    • subscribers_general с 17 августа 2022 года по 18 января 2024 года,
    • subscribers_detailed с 17 августа 2022 года по 18 января 2024 года.

Исследовательский анализ данных¶

Т.к. функция реакций в телеграме появились с 30 декабря 2021 года, то анализировть данные мы будем с этой даты.

In [ ]:
analyzes_date = pd.to_datetime('2021-12-30', format='%Y-%m-%d').date()
print(analyzes_date)
2021-12-30

Количество постов¶

Посмотрим, сколько постов было написано в канале.

In [ ]:
print(f'Постов в канале написано за все время - {channel_posts.id.nunique()}')
print(f'Постов в канале написано с {analyzes_date} -', channel_posts.query('post_date > @analyzes_date').id.nunique())
Постов в канале написано за все время - 23326
Постов в канале написано с 2021-12-30 - 7273

Всего в датасете 23326 постов, а с 30 декабря 2021 года было написано 7273 постов.
Визуализируем, как много постов было в нанале на определенную дату.

In [ ]:
# посчитаем сколько в каждую дату было написано постов
posts_dimanic = channel_posts.groupby(by='post_date').agg({'id':'count'}).reset_index().sort_values(by='post_date')
# посчитаем кумулятивную сумму, т.е. с накоплением

posts_dimanic['posts_cum'] = posts_dimanic['id'].cumsum()

# посчитаем сколько в каждую дату было написано постов с 30 декаря 2021 года
filter_posts_dimanic = (channel_posts.query('post_date > @analyzes_date')
  .groupby(by='post_date')
  .agg({'id':'count'})
  .reset_index()
  .sort_values(by='post_date')
  .rename(columns={'id':'posts_per_day'}))

# посчитаем кумулятивную сумму, т.е. с накоплением
filter_posts_dimanic['posts_cum'] = filter_posts_dimanic['posts_per_day'].cumsum()
In [ ]:
fig, ax = plt.subplots(1, 2, figsize=(12,6))

fig.suptitle('Количество постов в канале', fontsize=20)

ax[0].plot(posts_dimanic['post_date'], posts_dimanic['posts_cum'])
ax[0].set_title('Количество написанных постов за все время')
ax[0].set(xlabel='Дата', ylabel='Количество написанных постов к дате')
ax[0].tick_params(axis='x', labelrotation=45)
ax[0].grid(True)


ax[1].plot(filter_posts_dimanic['post_date'], filter_posts_dimanic['posts_cum'])
ax[1].set_title(f'Количество написанных постов c {analyzes_date}')
ax[1].set(xlabel='Дата', ylabel='Количество написанных постов к дате')
ax[1].tick_params(axis='x', labelrotation=45)
ax[1].grid(True)
fig.show();

Посмотрим, сколько постов написано в канале в день.

In [ ]:
print(filter_posts_dimanic['posts_per_day'].describe(percentiles=[0.05, 0.25, 0.5, 0.75, 0.95]))

filter_posts_dimanic.boxplot(column='posts_per_day', figsize=(8, 4))

plt.title(f'"ящик с усами" по количеству постов в день с {analyzes_date}')
plt.xlabel('')
plt.ylabel('Количество постов в день')
plt.show()
count    746.000000
mean       9.749330
std        5.000419
min        1.000000
5%         4.000000
25%        6.000000
50%        8.000000
75%       12.000000
95%       20.000000
max       37.000000
Name: posts_per_day, dtype: float64

Мы видим, что в канале бывает обычно от 1 до 21 постав в день, в среднем (и медианное значение) около 5 постов в день. Но бывают дни, когда пишутся аномально много постов - до 32. Посмотрим на самые активные дни.

In [ ]:
filter_posts_dimanic.query('posts_per_day > 29')
Out[ ]:
post_date posts_per_day posts_cum
82 2022-03-28 37 938
247 2022-09-10 33 3080
306 2022-11-08 30 3957

Возможные причины увеличения количества постов в день:
2022-03-28 - Оскар.
2022-09-10 - Венецианский кинофестиваль.
2022-11-08 - нет определенного повода, возможно, просто так совпало, что в этот день было много новостей, или это как-то связано с Днем рождения кинопоиска, которое было 7 ноября.

Подписки¶

Посмотрим, как менялось количество подписчиков в течение времени.

In [ ]:
subscribers_general.head()
Out[ ]:
date subscribers changes date_format
519 17.08.22 205805 62 2022-08-17
518 18.08.22 205921 116 2022-08-18
517 19.08.22 206163 242 2022-08-19
516 20.08.22 206493 330 2022-08-20
515 21.08.22 206772 279 2022-08-21
In [ ]:
plt.figure(figsize=(15, 5))
plt.title('Количество подписчиков канала по времени')

plt.plot(subscribers_general['date_format'], subscribers_general['subscribers'])
plt.xlabel('Дата')
plt.ylabel('Количество подписчиков')
plt.grid(True)
plt.show();

Мы видим, что были периоды резкого подъема в районе августа 2022, февраля 2023 и огромнвый рост после ноября 2023 года, но также мы видим, что в сентябре 2023 года был период, когда количество подписчиков падало.

Посмотрим, как люди подписывались/отписывались.

In [ ]:
print(subscribers_general['changes'].describe(percentiles=[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99]))

subscribers_general.boxplot(column='changes', figsize=(8, 4))

plt.title(f'"ящик с усами" по ежедневному изменению подписчиков ')
plt.xlabel('')
plt.ylabel('Количество подписок/отписок')
plt.show()
count     520.000000
mean      664.682692
std      1495.368324
min     -1254.000000
1%       -472.810000
5%       -208.350000
25%         7.250000
50%       153.500000
75%       431.750000
95%      4440.600000
99%      6145.330000
max      9844.000000
Name: changes, dtype: float64

Мы видим, что обычно количество подписчиков в день меняется от приблизительно минус 200, до плюс тысячи.
Нам интересны дни, когда происходило аномальное количество подписок/отписок, посмотрим на 1 и 99 перцентиль (0,01 и 0,99 квантиль).

In [ ]:
subscribers_max_limit = subscribers_general['changes'].quantile(0.99)
subscribers_min_limit = subscribers_general['changes'].quantile(0.01)

abnormal_subscriptions = subscribers_general.query('changes <= @subscribers_min_limit | changes >= @subscribers_max_limit')
print('Количество дней с аномальными подписками/отписками - ', len(abnormal_subscriptions))
display(abnormal_subscriptions)
#abnormal_subscriptions.to_csv('/content/drive/My Drive/abnormal_subscriptions.csv', index=False)
Количество дней с аномальными подписками/отписками -  12
date subscribers changes date_format
312 12.03.23 251101 9844 2023-03-12
126 14.09.23 290140 -475 2023-09-14
97 13.10.23 289423 -473 2023-10-13
94 16.10.23 288171 -511 2023-10-16
93 17.10.23 287673 -498 2023-10-17
63 16.11.23 322213 8303 2023-11-16
62 17.11.23 331934 9721 2023-11-17
57 22.11.23 358364 6163 2023-11-22
55 24.11.23 371210 6776 2023-11-24
36 13.12.23 440327 -1254 2023-12-13
35 14.12.23 439806 -521 2023-12-14
27 22.12.23 472752 6348 2023-12-22
In [ ]:
abnormal_subscriptions_posts = channel_posts.query('post_date in @abnormal_subscriptions.date_format')
abnormal_subscriptions_posts = abnormal_subscriptions_posts[['id',
                                    'date_time',
                                    'text',
                                    'type_attachment',
                                    'views',
                                    'forwarded',
                                    'reactions_count',
                                    'comments']]
print('Постов в аномальные дни -', len(abnormal_subscriptions_posts))
#abnormal_subscriptions_posts.to_csv('/content/drive/My Drive/abnormal_subscriptions_posts.csv', index=False)
Постов в аномальные дни - 97

Вовлеченность¶

Посчитаем вовлеченность для кажого поста, это сделаем по формуле:
(Количество комментариев + колличество реакций + количество репостов) * 100 / количество просмотров.

In [ ]:
channel_posts_filtered = channel_posts.query('post_date > @analyzes_date')
channel_posts_filtered['er'] = ((channel_posts_filtered['reactions_count']
                                          + channel_posts_filtered['comments']
                                          + channel_posts_filtered['forwarded'])
                                          * 100
                                          /channel_posts_filtered['views'] ).round(2)
In [ ]:
print(channel_posts_filtered['er'].describe(percentiles=[0.05, 0.25, 0.5, 0.75, 0.95]))

channel_posts_filtered.boxplot(column='er', figsize=(8, 4))

plt.title(f'"ящик с усами" по вовлеченности (ER) с {analyzes_date}')
plt.xlabel('')
plt.ylabel('Вовлеченность')
plt.show()
count    7273.000000
mean        1.136486
std         0.848679
min         0.080000
5%          0.330000
25%         0.590000
50%         0.920000
75%         1.440000
95%         2.590000
max        13.120000
Name: er, dtype: float64

Мы видим, что обычно "показатель вовлеченности" в среднем около 0,85, медианное значение - 0,92. Аномально большим считается показатель больше 2, но есть рекодсмен - более 12. Посмотрим на посты с самой высокой вовлеченностью, т.е. более 10.

In [ ]:
#Сохраним топ 10 постов по вовлеченности
top_10_posts_er = (channel_posts_filtered[['er',
                                           'id',
                                           'date_time',
                                           'text',
                                           'type_attachment',
                                           'views',
                                           'forwarded',
                                           'reactions_count',
                                           'comments']].sort_values(by='er', ascending=False).head(10))
#также сохраним топ10 постов с худшей вовлеченностью
least_10_posts_er = (channel_posts_filtered[['er',
                                           'id',
                                           'date_time',
                                           'text',
                                           'type_attachment',
                                           'views',
                                           'forwarded',
                                           'reactions_count',
                                           'comments']].sort_values(by='er', ascending=False).tail(10))
In [ ]:
display(top_10_posts_er.head())
er id date_time text type_attachment views forwarded reactions_count comments
8984 13.12 27739 2022-08-22 16:40:57+03:00 Привет! Этот текст пишет Антон из SMM команды Кинопоиска. Сейчас я уже отдыхаю, все хорошо. Главное, что посты про дарконов выложил. Спасибо за ваше внимание, много приятных комментариев. \n\nЗавтра у меня, конечно же, выходной ❤️ MessageMediaPhoto 82651.0 3141.0 7245 460
1444 11.86 35634 2023-10-29 04:14:56+03:00 Умер звезда «Друзей» Мэттью Перри. Ему было 54 года.\n\nФото: Getty Images MessageMediaPhoto 62826.0 2124.0 5190 136
4089 11.75 32799 2023-04-26 15:15:34+03:00 5 тысяч сердечек на этом посте и мы ставим Кена-Гослинга на аватарку канала 💖 MessageMediaPhoto 72894.0 383.0 8012 169
3797 10.90 33123 2023-05-22 16:22:32+03:00 Ушла эпоха!\n\n__Будете __[__скучать__](https://t.me/kinopoisk_Industry/1936)__ по привычному дубляжу Леонардо__ __ДиКаприо?\n__❤️__ - да__ \n👎__ - нет__ MessageMediaPhoto 70984.0 1045.0 6529 164
2332 8.98 34691 2023-09-04 12:30:25+03:00 Кристофер Нолан — лучший режиссер последних 25 лет по версии [Rotten Tomatoes](https://editorial.rottentomatoes.com/article/best-directors-of-the-last-25-years/).\n\n__Согласны?\n\n👍 — да, гений!\n👎 — нет, переоценен\n\n__Фото: Pascal Le Segretain / Getty Images MessageMediaPhoto 49100.0 146.0 4207 58

На первом месте пост от SMM специалиста команды, после того, как он запустил пуш сообщение по ошибке пользователям приложения.
На втором месте - пост о смерти Мэттью Перри, которая оказалась для всех большой неожиданностью.
На третьем и четвертом месте - посты с призывом ставить реакции.

Также посчитаем суточный ER, он считается, как средний ER в сутки.

In [ ]:
er_day = channel_posts_filtered.groupby('post_date', as_index=False).agg({'er':'mean'}).sort_values(by='post_date')
In [ ]:
plt.figure(figsize=(15, 5))
plt.title('Вовлеченность подписчиков (ER) по дням')

plt.plot(er_day['post_date'], er_day['er'])
plt.xlabel('Дата')
plt.ylabel('er')
plt.grid(True)
plt.show();

Также посчитаем ER в месяц, как медианное значение ER в месяц.

In [ ]:
er_day['month'] = pd.to_datetime(er_day['post_date'], format='%Y-%m-%d').dt.to_period("M")
er_month = er_day.groupby('month', as_index=False).agg({'er':'median'}).sort_values(by='month')
er_month['month'] = er_month['month'].astype('str')
In [ ]:
plt.figure(figsize=(15, 5))
plt.title('Вовлеченность подписчиков (ER) по месяцам')
plt.plot(er_month['month'], er_month['er'])
plt.xlabel('Месяц')
plt.tick_params(axis='x', labelrotation=45)
plt.ylabel('er')
plt.grid(True)
plt.show();

Мы видим, что со временем вовлеченность пользователей росла до мая месяца, после чего начала радать, а с июля начала расти до октября, как раз когда выпустили чат бота. Провал в июле можно объяснить тем, что телеграм планировал вводить систему рекомендаций по каналам из-за чего менял органические алгоритмы.

Вовлеченность по просмотрам¶

Посчитаем вовлеченность по просмотрам (ERR) по дням, для этого:
(среднее количество просмотров постов в месяц / количество подписчиков) * 100

In [ ]:
# на каждую дату посчитаем среднее количество просмотров постов
err_day = channel_posts_filtered.groupby('post_date', as_index=False).agg({'views':'mean'})

# присоединим количество подписчиков на какждый день
err_day = pd.merge(err_day, subscribers_general[['date_format', 'subscribers']], left_on='post_date', right_on='date_format', how='inner')

# посчитаем err
err_day['err'] = (err_day['views']* 100/ err_day['subscribers'] ).round(2)

# удалим лишнюю колонку
err_day = err_day.drop(columns=['date_format'])

# отсортируем таблицу по дате
err_day = err_day.sort_values(by='post_date')

Построим график.

In [ ]:
plt.figure(figsize=(15, 5))
plt.title('Вовлеченность (ERR) по просмотрам по дням')

plt.plot(err_day['post_date'], err_day['err'])
plt.xlabel('Дата')
plt.ylabel('ERR')
plt.grid(True)
plt.show();

Мы видим, что ERR всегда колеблется от 15% до 30%, с разовыми сверхпиками.

In [ ]:
print(err_day['err'].describe(percentiles=[0.05, 0.25, 0.5, 0.75, 0.99]))

err_day.boxplot(column='err', figsize=(8, 4))

plt.title(f'"ящик с усами" по вовлеченности по просмотрам (ERR) с {analyzes_date}')
plt.xlabel('')
plt.ylabel('ERR')
plt.show()
count    520.000000
mean      21.145865
std        3.928895
min       13.680000
5%        15.999000
25%       18.170000
50%       20.345000
75%       23.772500
99%       30.946700
max       44.390000
Name: err, dtype: float64

Как мы видим, ERR обычно в пределах от 13,68 до 31, средгнее значение по дням - 21,15, медианное - 20,29. Но есть 3 дня, когда ERR была больше 31, до 44,39, посмотрим на эти даты.

In [ ]:
err_day.query('err > 30.95')
Out[ ]:
post_date views subscribers err
144 2023-01-08 73977.500000 229026 32.30
178 2023-02-11 104154.857143 234613 44.39
192 2023-02-25 73560.833333 236458 31.11
213 2023-03-18 78906.250000 253514 31.13
311 2023-06-24 104079.000000 269270 38.65
366 2023-08-18 89854.166667 290244 30.96
In [ ]:
err_max_posts = channel_posts_filtered.query('post_date in @err_day.post_date')
err_max_posts = err_max_posts[['er',
                                    'id',
                                    'date_time',
                                    'text',
                                    'type_attachment',
                                    'views',
                                    'forwarded',
                                    'reactions_count',
                                    'comments']]
display(err_max_posts.sort_values(by='er', ascending=False).head(5))
er id date_time text type_attachment views forwarded reactions_count comments
8984 13.12 27739 2022-08-22 16:40:57+03:00 Привет! Этот текст пишет Антон из SMM команды Кинопоиска. Сейчас я уже отдыхаю, все хорошо. Главное, что посты про дарконов выложил. Спасибо за ваше внимание, много приятных комментариев. \n\nЗавтра у меня, конечно же, выходной ❤️ MessageMediaPhoto 82651.0 3141.0 7245 460
1444 11.86 35634 2023-10-29 04:14:56+03:00 Умер звезда «Друзей» Мэттью Перри. Ему было 54 года.\n\nФото: Getty Images MessageMediaPhoto 62826.0 2124.0 5190 136
4089 11.75 32799 2023-04-26 15:15:34+03:00 5 тысяч сердечек на этом посте и мы ставим Кена-Гослинга на аватарку канала 💖 MessageMediaPhoto 72894.0 383.0 8012 169
3797 10.90 33123 2023-05-22 16:22:32+03:00 Ушла эпоха!\n\n__Будете __[__скучать__](https://t.me/kinopoisk_Industry/1936)__ по привычному дубляжу Леонардо__ __ДиКаприо?\n__❤️__ - да__ \n👎__ - нет__ MessageMediaPhoto 70984.0 1045.0 6529 164
2332 8.98 34691 2023-09-04 12:30:25+03:00 Кристофер Нолан — лучший режиссер последних 25 лет по версии [Rotten Tomatoes](https://editorial.rottentomatoes.com/article/best-directors-of-the-last-25-years/).\n\n__Согласны?\n\n👍 — да, гений!\n👎 — нет, переоценен\n\n__Фото: Pascal Le Segretain / Getty Images MessageMediaPhoto 49100.0 146.0 4207 58

Также посчитаем ERR по месяцам, как среднее.

In [ ]:
err_day['month'] = pd.to_datetime(err_day['post_date'], format='%Y-%m-%d').dt.to_period("M")
err_month = err_day.groupby('month', as_index=False).agg({'err':'median'}).sort_values(by='month')
err_month['month'] = err_month['month'].astype('str')
In [ ]:
plt.figure(figsize=(15, 5))
plt.title('Медианная вовлеченность подписчиков (ERR) по месяцам')
plt.plot(err_month['month'], err_month['err'])
plt.xlabel('Месяц')
plt.tick_params(axis='x', labelrotation=45)
plt.ylabel('er')
plt.grid(True)
plt.show();

На графике мы можем видеть, что среднемесячная вовлеченность (ERR) росла с ноября 2022 по январь 2023 года, после чего, можно сказать, держалась на одном уровне, а после июля 2023 года резко пошла на спад. и Только в октябре 2023 года стала снвоа расти.

Размер постов¶

Посмотрим, какого размера были посты в канале.

In [ ]:
channel_posts_filtered['symbols'] = channel_posts_filtered['text'].apply(lambda x: len(x))
In [ ]:
print(channel_posts_filtered['symbols'].describe(percentiles=[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99]))

channel_posts_filtered.boxplot(column='symbols', figsize=(8, 4))

plt.title(f'"ящик с усами" по количеству символов в постах')
plt.xlabel('')
plt.ylabel('количество символов в посте')
plt.show()
count    7273.000000
mean      447.914753
std       413.827414
min         3.000000
1%         29.000000
5%         76.000000
25%       227.000000
50%       349.000000
75%       529.000000
95%      1114.000000
99%      2189.400000
max      4868.000000
Name: symbols, dtype: float64

Мы видим. что обычно в постах от 3 до 1000 сиволов, в среднем 448, медианное значение 349. Очень редкими являются посты более 2189 символов.

Посчитаем, сколько в постах знаков препинания.

In [ ]:
channel_posts_filtered['punctuation'] = channel_posts_filtered['text'].apply(lambda x: sum(map(x.count, [',',
                                                                                                         '.',
                                                                                                         '—',
                                                                                                         ':',
                                                                                                         ';',
                                                                                                         '-',
                                                                                                         '!',
                                                                                                         '?'])));

?.:.?— -!:.

In [ ]:
print(channel_posts_filtered['punctuation'].describe(percentiles=[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99]))

channel_posts_filtered.boxplot(column='punctuation', figsize=(8, 4))

plt.title(f'"ящик с усами" по количеству знаков перпинания в постах')
plt.xlabel('')
plt.ylabel('количество знаков препинания в посте')
plt.show()
count    7273.000000
mean       15.341812
std        14.880253
min         0.000000
1%          1.000000
5%          2.000000
25%         7.000000
50%        11.000000
75%        18.000000
95%        41.400000
99%        82.000000
max       139.000000
Name: punctuation, dtype: float64

Мы видим, что в постах бывает от 0 до 139 знаков, но также в постах есть ссылки, которые также состоят из знаков перпинания (двоеточий, точек и технических пробелов), что искажает нам анализ.

In [ ]:
#Подготовим функцию, которая удалит ссылки в наших постах
def clean_data(df):

    df['clean_text'] = df['text'].str.replace('http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', ' ')
#применим функцию
clean_data(channel_posts_filtered)

Посчитаем теперь в очищенных данных количество символов и знаков препинания.

In [ ]:
channel_posts_filtered['clear_symbols'] = channel_posts_filtered['clean_text'].apply(lambda x: len(x))
channel_posts_filtered['clear_punctuation'] = channel_posts_filtered['clean_text'].apply(lambda x: sum(map(x.count, [',',
                                                                                                                     '.',
                                                                                                                     '—',
                                                                                                                     ':',
                                                                                                                     ';',
                                                                                                                     '-',
                                                                                                                     '!',
                                                                                                                     '?'])))
In [ ]:
print(channel_posts_filtered['clear_symbols'].describe(percentiles=[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99]))

channel_posts_filtered.boxplot(column='clear_symbols', figsize=(8, 4))

plt.title(f'"ящик с усами" по количеству символов в постах')
plt.xlabel('')
plt.ylabel('количество символов в посте')
plt.show()
count    7273.000000
mean      363.017324
std       323.224674
min         3.000000
1%         27.720000
5%         59.600000
25%       179.000000
50%       279.000000
75%       443.000000
95%       899.000000
99%      1706.560000
max      3998.000000
Name: clear_symbols, dtype: float64

Мы видим, что обычно в постах бывает от 3 до приблизительно 900 символов, но также встречаются посты до 3998 символов. В среднем в постах 363 символа, медианное значение 279 символа.

In [ ]:
print(channel_posts_filtered['clear_punctuation'].describe(percentiles=[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99]))

channel_posts_filtered.boxplot(column='clear_punctuation', figsize=(8, 4))

plt.title(f'"ящик с усами" по количеству знаков перпинания в постах')
plt.xlabel('')
plt.ylabel('количество знаков препинания в посте')
plt.show()
count    7273.000000
mean        9.348824
std         8.780056
min         0.000000
1%          0.000000
5%          1.000000
25%         4.000000
50%         7.000000
75%        11.000000
95%        24.000000
99%        47.280000
max       116.000000
Name: clear_punctuation, dtype: float64

Также мы видим, что в постах обычно бывает от 0 до 21 знаков препинания, но есть посты и до 120 знаков препинания. Посмотрим на этот пост, может быть это кая-то ошибка.

In [ ]:
display(channel_posts_filtered.query('clear_punctuation > 100')['text'])
13037    Саша Амато, [Golden Chihuahua](https://t.me/goldchihuahua)\n\nЯ успел посмотреть три серии и могу точно сказать, что главная проблема сериала в том, что его создатели страдают от странной слепоты к той культуре, которая породила Анну Делви. Когда я читал оригинальную статью, это была в первую очередь история про белого привилегированного человека, обладающего якобы безупречной биографией и произошедшего из очень богатой семьи. Культура и общество всегда поддерживали таких девушек. Будучи белой, очень легко втереться в доверие и попасть в высшее общество Нью-Йорка, во все арт- и фешен-тусовки (вряд ли Делви удалось бы это сделать, если бы она была, например, афроамериканкой или мексиканкой). Преступница Делви, которая манипулирует людьми и обманывает их, и есть продукт этого лицемерного общества, погрязшего в стереотипах. Тут показателен недавний скандал с «И просто так», где в одном из эпизодов звучит фраза: «Русская проститутка — обычное дело в дорогой недвижимости». Мне хотелось бы увидеть именно критический сериал, которые бы глубже смог изучить тему привилегированности. Было бы намного интереснее, если бы сериал был посвящен не столько Анне, сколько обществу, которое ей позволило стать такой успешной аферисткой.\n\nЮлия Пош, «[Антиглянец](https://t.me/sncmag)»\n\nМы сами смотрим сериал от Netflix про нью-йоркскую мошенницу Анну Дельви и вам советуем. Первая серия чуть тяжеловесна, но дальше просто шик как снято. Костюмы, актеры, локации, обманы — не оторваться. И главная актриса подобрана отлично. Из весьма скромных 300 000 долларов, заплаченных за историю Netflix, реальная Анна Сорокина-Делви сейчас понемногу возвращает долг своим невольным кредиторам.\n\n«[Синька](https://t.me/thynk)»\n\nNetflix начал заметную кампанию против популярных мошенников: несколько недель назад вышел «Аферист из Tinder», а теперь — история аферистки из Нью-Йорка, которая явно стоит своего внимания и отлично учит нас всех хорошо лгать и продумывать все свои легенды заранее, чтобы не превратиться из Анны Делви в Анну Сорокину, как в этом сериале.\n\n«[Луис Иванович Вьютон](https://t.me/LIVuiton)»\n\nОх, если бы в сериале снялась сама Анна, я бы смотрел эту работу куда более охотно, но Джулия играла Сорокину немного не так, потому что повадки Рут из «Озарка» все-таки сохранились, да и бойкую журналистку Вивьен Кент (Анна Кламски) тут показывают куда чаще, чем главную героиню. Долго, нудно и явно не так, как распиарили. Мне кажется, Анна и тут «обманула» Netflix.\n\n«[Алло, Галочка, ты не поверишь](https://t.me/Hello_Galochka)!»\n\n«Все дело в деталях. А эта сука была безупречна!» — говорит один из героев сериала про Анну Делви, которая Сорокина. И он чертовски прав. Один из режиссеров «Изобретая Анну» — Дэвид Фрэнкел («Дьявол носит Prada»). Вот уж кто точно знает толк в деталях. Несмотря на то, что реальная история довольно драматичная, сериал получился динамичным, стильным и местами даже смешным. Авторам невнятной «Эмили в Париже» того же Netflix есть чему поучиться. Отдельный привет не только актрисе Джулии Гарнер, к героине которой во время просмотра испытываешь самые разные чувства — от ненависти до восхищения, — но и, собственно, самой Анне; суд обязал ее выплатить в общей сложности 123 000 долларов, а за свою историю она получила от Netflix 320 000. Снова профит!\n\nКатя Федорова, [Good morning, Karl](https://t.me/goodmorningkarl)!\n\nИстория Анны Делви — это что-то из серии «нарочно не придумаешь». Я была заворожена ей еще с момента выхода той самой статьи, которая легла в основу сериала, и очень его ждала. «Изобретая Анну» получился очень динамичным и зрелищным. Правда, поначалу мне казалось, что за всеми сценами роскошной жизни как будто потерялся ответ на вопрос, что же такого в Анне, которая смогла развести на огромные деньги финансовых воротил Нью-Йорка, но последние две серии поразили меня настолько, что я уже готова сама устроить краудфандинг на ее новые проекты. Добавьте к этому роскошные наряды, блестящую игру актеров (подружки Анны — мои фаворитки) и порой гомерически смешные диалоги. В общем, смотрите обязательно!
Name: text, dtype: object

Мы видим, что это пост - объединенные рецензии нескольких человек.

Посмотрим, каких постов в канале больше, поделим их на 4 категории:

  • до 50 символов - очень короткие,
  • от 50 до 500 - короткие,
  • от 500 до 1000 - обычные,
  • от 1000 - большие.
In [ ]:
#подготовим функцию
def post_volume(volume):
  try:
    if volume < 50:
      return 'очень короткий пост'
    elif 50 <= volume < 500:
      return 'короткий пост'
    elif 500 <= volume < 1000:
      return 'обычный пост'
    else:
      return 'длинный пост'
  except:
    pass

Применим функцию

In [ ]:
channel_posts_filtered['post_cat'] = channel_posts_filtered['clear_symbols'].apply(post_volume)
display(channel_posts_filtered.groupby(by='post_cat', as_index=False)['id'].count())

channel_posts_filtered['post_cat'].value_counts().plot(kind="pie",
          autopct='%1.1f%%',
          explode=[0.05, 0.01, 0.01, 0.01],
          legend=True,
          title='Доля постов по категориям',
          ylabel='',
          labeldistance=None,
          figsize=(6, 6),
          cmap='Pastel2').legend(bbox_to_anchor=(1.5, 1));
post_cat id
0 длинный пост 248
1 короткий пост 5506
2 обычный пост 1229
3 очень короткий пост 290

75% постов до 50 символов, вероятно, это анонсы, постеры, кадры из фильмов. 17% - посты обычной длины, 4% очень коротких постов и 3,4% очень длинных постов. Посмотрим, как распределяются медианный ER по категриям.

In [ ]:
channel_posts_filtered.groupby(by='post_cat')['er'].median().sort_values(ascending=False).plot(kind='bar',
       figsize=(12,5),
       title='Медианный ER по категориям постов',
       xlabel='Категория поста',
       ylabel='Медианный ER',
       rot=0
      );
In [ ]:
channel_posts_filtered.groupby(by='post_cat')['views'].median().sort_values(ascending=False).plot(kind='bar',
       figsize=(12,5),
       title='Медианное количество просмотров по категориям постов',
       xlabel='Категория поста',
       ylabel='Медианное количество просмотров',
       rot=0
      );
In [ ]:
fig, axes = plt.subplots(nrows=4, figsize = (10, 15))
fig.tight_layout(pad=5.0)

# ER
ax1 = sns.boxplot(x='er', y='post_cat', data=channel_posts_filtered, color='#82E0AA', ax=axes[0])
ax1.set(title = 'ER по категориям постов', ylabel = 'Категория', xlabel = 'ER')

# Просмотры
ax2 = sns.boxplot(x='views', y='post_cat', data=channel_posts_filtered, color='#85C1E9', ax=axes[1])
ax2.set(title = 'Просмотов по категориям постов', ylabel = 'Категория', xlabel = 'Количество просмотров')

# Репосты
ax3 = sns.boxplot(x='forwarded', y='post_cat', data=channel_posts_filtered, color='#F5B7B1', ax=axes[2])
ax3.set(title = 'Репостов по категориям постов', ylabel = 'Категория', xlabel = 'Количество репостов')

# Комментарии
ax4 = sns.boxplot(x='comments', y='post_cat', data=channel_posts_filtered, color='#FAD7A0', ax=axes[3])
ax4.set(title = 'Комментариев по категориям постов', ylabel = 'Категория', xlabel = 'Количество комментариев')

plt.show()

Мы видим, что у коротких постов выше всего ER, количество просмотров и репостов. Но разница по просмотрам у категорий не очень большая. А вот ER у больших постов значительно ниже, чем у других категорий, возможно, пользователи ленятся читать большие тексты и поэтому и не взаимодействуют с ними.

Употребляемые слова¶

Посмотрим, какие слова чаще употребляются в постах, в зависимости от их ER - выше и ниже медианного значения.

Сохраним текст в разные переменные.

In [ ]:
min_er_text = ' '.join(channel_posts_filtered.query('er <= @channel_posts_filtered.er.median()')['clean_text'])
max_er_text = ' '.join(channel_posts_filtered.query('er > @channel_posts_filtered.er.median()')['clean_text'])
In [ ]:
# добавляем к стандартным знакам пунктуации кавычки и многоточие
spec_chars = string.punctuation + '«»\t—…’'
# делаем все слова с маленькой буквы
min_er_text = min_er_text.lower()
max_er_text = max_er_text.lower()

# очищаем текст от знаков препинания
min_er_text = "".join([ch for ch in min_er_text if ch not in spec_chars])
max_er_text = "".join([ch for ch in max_er_text if ch not in spec_chars])
In [ ]:
# меняем переносы строк на пробелы
min_er_text = re.sub('\n', ' ', min_er_text)
max_er_text = re.sub('\n', ' ', max_er_text)
In [ ]:
# убираем из текста цифры
min_er_text = "".join([ch for ch in min_er_text if ch not in string.digits])
max_er_text = "".join([ch for ch in max_er_text if ch not in string.digits])
In [ ]:
# токенизируем текст
min_er_text_tokens = word_tokenize(min_er_text)
max_er_text_tokens = word_tokenize(max_er_text)

# переводим токены в текстовый формат
min_er_text = nltk.Text(min_er_text_tokens)
max_er_text = nltk.Text(max_er_text_tokens)

# и считаем слова в тексте по популярности
min_er_text_fdist = FreqDist(min_er_text)
max_er_text_fdist = FreqDist(max_er_text)

# выводим первые 5 популярных слов
print(min_er_text_fdist.most_common(5))
print(max_er_text_fdist.most_common(5))
[('в', 7641), ('и', 7410), ('на', 3630), ('с', 2339), ('о', 1922)]
[('в', 6279), ('и', 5348), ('на', 3384), ('с', 1701), ('не', 1220)]

В самых популярных словах у нас предлоги. Необходимо очистить текст от них.

In [ ]:
# добавляем русские и английские стоп-слова
russian_stopwords = stopwords.words("russian")
russian_stopwords += stopwords.words("english")
In [ ]:
# перестраиваем токены, не учитывая стоп-слова
min_er_text_tokens = [token.strip() for token in min_er_text_tokens if token not in russian_stopwords]
max_er_text_tokens = [token.strip() for token in max_er_text_tokens if token not in russian_stopwords]
# снова приводим токены к текстовому виду
min_er_text = nltk.Text(min_er_text_tokens)
max_er_text = nltk.Text(max_er_text_tokens)
# считаем заново частоту слов
min_er_text_fdist = FreqDist(min_er_text)
max_er_text_fdist = FreqDist(max_er_text)
# показываем самые популярные
print(min_er_text_fdist.most_common(10))
print(max_er_text_fdist.most_common(10))
[('фильм', 783), ('это', 605), ('кинопоиск', 532), ('который', 521), ('кино', 520), ('фильма', 483), ('сериала', 469), ('рассказываем', 450), ('года', 438), ('сериал', 428)]
[('фильм', 760), ('это', 586), ('фото', 570), ('фильма', 528), ('года', 517), ('кинопоиск', 515), ('подписывайтесь', 473), ('сериала', 404), ('премьера', 400), ('🔥', 363)]

мы видим, что в тексте часто встречается слово "это", добавим его в стоп слова.

In [ ]:
# добавляем свои слова в этот список
russian_stopwords.extend(['это'])
# перестраиваем токены, не учитывая стоп-слова
min_er_text_tokens = [token.strip() for token in min_er_text_tokens if token not in russian_stopwords]
max_er_text_tokens = [token.strip() for token in max_er_text_tokens if token not in russian_stopwords]
# снова приводим токены к текстовому виду
min_er_text = nltk.Text(min_er_text_tokens)
max_er_text = nltk.Text(max_er_text_tokens)
# считаем заново частоту слов
min_er_text_fdist = FreqDist(min_er_text)
max_er_text_fdist = FreqDist(max_er_text)
# показываем самые популярные
print(min_er_text_fdist.most_common(10))
print(max_er_text_fdist.most_common(10))
[('фильм', 783), ('кинопоиск', 532), ('который', 521), ('кино', 520), ('фильма', 483), ('сериала', 469), ('рассказываем', 450), ('года', 438), ('сериал', 428), ('подписывайтесь', 385)]
[('фильм', 760), ('фото', 570), ('фильма', 528), ('года', 517), ('кинопоиск', 515), ('подписывайтесь', 473), ('сериала', 404), ('премьера', 400), ('🔥', 363), ('лет', 357)]

Сделаем облако слов.

In [ ]:
# переводим всё в текстовый формат
min_er_text_raw = " ".join(min_er_text)
max_er_text_raw = " ".join(max_er_text)
In [ ]:
# готовим размер картинки
wordcloud = WordCloud(width=1600, height=800).generate(min_er_text_raw)
plt.figure( figsize=(20,10), facecolor='k')
# добавляем туда облако слов
plt.imshow(wordcloud)
# выключаем оси и подписи
plt.axis("off")
# убираем рамку вокруг
plt.tight_layout(pad=0)
# выводим картинку на экран
plt.show()
In [ ]:
# готовим размер картинки
wordcloud = WordCloud(width=1600, height=800).generate(max_er_text_raw)
plt.figure( figsize=(20,10), facecolor='k')
# добавляем туда облако слов
plt.imshow(wordcloud)
# выключаем оси и подписи
plt.axis("off")
# убираем рамку вокруг
plt.tight_layout(pad=0)
# выводим картинку на экран
plt.show()

Мы видим, что много повторяющихся слов, которые нам надо очистить еще, а именно привести их к нормальной форме. Нормальная форма слова — это то, как оно записано в словаре:

  • для глаголов это будет неопределённая форма;
  • для существительных — единственное число, именительный падеж;
  • для прилагательных — единственное число, именительный падеж, мужской род.
In [ ]:
# добавляем анализатор слов
morph = pymorphy2.MorphAnalyzer()
# тут будут те же самые слова, что и в исходном тексте, но в нормальной форме
min_er_filtered_tokens = []
max_er_filtered_tokens = []
# перебираем все слова в исходном тексте
#для er<= median
for token in min_er_text_tokens:
    # получаем нормальную форму текущего слова
    p = morph.parse(str(token))[0]
    # добавляем его в новый массив
    min_er_filtered_tokens.append(p.normal_form)
#для er> median
for token in max_er_text_tokens:
    # получаем нормальную форму текущего слова
    p = morph.parse(str(token))[0]
    # добавляем его в новый массив
    max_er_filtered_tokens.append(p.normal_form)
In [ ]:
# переводим токены в текстовый формат
min_er_text = nltk.Text(min_er_filtered_tokens)
max_er_text = nltk.Text(max_er_filtered_tokens)
In [ ]:
# считаем заново частоту слов
min_er_text_fdist = FreqDist(min_er_text)
max_er_text_fdist = FreqDist(max_er_text)
# показываем самые популярные
print(min_er_text_fdist.most_common(10))
print(max_er_text_fdist.most_common(10))
[('фильм', 2235), ('который', 1627), ('сериал', 1425), ('кинопоиск', 1253), ('новый', 1238), ('год', 1119), ('наш', 991), ('главный', 845), ('подкаст', 741), ('режиссёр', 723)]
[('фильм', 2163), ('год', 1352), ('который', 1146), ('сериал', 1093), ('новый', 1051), ('кинопоиск', 917), ('выйти', 642), ('первый', 636), ('роль', 630), ('наш', 611)]

Мы видим, что есть много слов, котоыре встречаются и не несут для нас никакой пользы: кинопоиск, который, год

In [ ]:
# добавляем свои слова в этот список
russian_stopwords.extend(['это', 'кинопоиск', 'который', 'год'])
# перестраиваем токены, не учитывая стоп-слова
min_er_filtered_tokens = [token.strip() for token in min_er_filtered_tokens if token not in russian_stopwords]
max_er_filtered_tokens = [token.strip() for token in max_er_filtered_tokens if token not in russian_stopwords]
# снова приводим токены к текстовому виду
min_er_text = nltk.Text(min_er_filtered_tokens)
max_er_text = nltk.Text(max_er_filtered_tokens)
# считаем заново частоту слов
min_er_text_fdist = FreqDist(min_er_text)
max_er_text_fdist = FreqDist(max_er_text)
# показываем самые популярные
print(min_er_text_fdist.most_common(10))
print(max_er_text_fdist.most_common(10))
[('фильм', 2235), ('сериал', 1425), ('новый', 1238), ('наш', 991), ('главный', 845), ('подкаст', 741), ('режиссёр', 723), ('рассказывать', 691), ('первый', 617), ('роль', 614)]
[('фильм', 2163), ('сериал', 1093), ('новый', 1051), ('выйти', 642), ('первый', 636), ('роль', 630), ('наш', 611), ('фото', 570), ('премьера', 569), ('главный', 557)]

Также мы видим, что фильм, сериал, новый встречаются часто в обоих списках, очистим и от них.

In [ ]:
# добавляем свои слова в этот список
russian_stopwords.extend(['фильм', 'сериал', 'новый'])
# перестраиваем токены, не учитывая стоп-слова
min_er_filtered_tokens = [token.strip() for token in min_er_filtered_tokens if token not in russian_stopwords]
max_er_filtered_tokens = [token.strip() for token in max_er_filtered_tokens if token not in russian_stopwords]
# снова приводим токены к текстовому виду
min_er_text = nltk.Text(min_er_filtered_tokens)
max_er_text = nltk.Text(max_er_filtered_tokens)
# считаем заново частоту слов
min_er_text_fdist = FreqDist(min_er_text)
max_er_text_fdist = FreqDist(max_er_text)
# показываем самые популярные
print(min_er_text_fdist.most_common(10))
print(max_er_text_fdist.most_common(10))
[('наш', 991), ('главный', 845), ('подкаст', 741), ('режиссёр', 723), ('рассказывать', 691), ('первый', 617), ('роль', 614), ('выйти', 597), ('хороший', 553), ('сезон', 538)]
[('выйти', 642), ('первый', 636), ('роль', 630), ('наш', 611), ('фото', 570), ('премьера', 569), ('главный', 557), ('сезон', 526), ('режиссёр', 503), ('актёр', 488)]

Готовим текст для облака слов.

In [ ]:
min_er_text_raw = " ".join(min_er_text)
max_er_text_raw = " ".join(max_er_text)

Облако слов для постов с ER меньше или равным медианного значения

In [ ]:
# готовим размер картинки
wordcloud = WordCloud(width=1600, height=800).generate(min_er_text_raw)
plt.figure( figsize=(20,10), facecolor='k')
# добавляем туда облако слов
plt.imshow(wordcloud)
# выключаем оси и подписи
plt.axis("off")
# убираем рамку вокруг
plt.tight_layout(pad=0)
# выводим картинку на экран
plt.show()

Облако слов для постов с ER больше медианного значения

In [ ]:
# готовим размер картинки
wordcloud = WordCloud(width=1600, height=800).generate(max_er_text_raw)
plt.figure( figsize=(20,10), facecolor='k')
# добавляем туда облако слов
plt.imshow(wordcloud)
# выключаем оси и подписи
plt.axis("off")
# убираем рамку вокруг
plt.tight_layout(pad=0)
# выводим картинку на экран
plt.show()

Построим облака слов для существительных, прилагательных и глаголов.

In [ ]:
# новые переменные для существительных, прилагательных и глаголов
min_er_noun_tokens = []
min_er_adjf_tokens = []
min_er_verb_tokens = []
# перебираем все слова в исходном тексте
for token in min_er_filtered_tokens:
    # получаем нормальную форму текущего слова
    p = morph.parse(str(token))[0]
    if "NOUN" in p.tag:
        # добавляем его в массив c существительными
        min_er_noun_tokens.append(p.normal_form)
    elif "ADJF" in p.tag or "ADJS" in p.tag:
        # добавляем его в массив c прилагательными
        min_er_adjf_tokens.append(p.normal_form)
    elif "VERB" in p.tag or "INFN" in p.tag:
        # добавляем его в массив c глаголами
        min_er_verb_tokens.append(p.normal_form)
In [ ]:
# новые переменные для существительных, прилагательных и глаголов
max_er_noun_tokens = []
max_er_adjf_tokens = []
max_er_verb_tokens = []
# перебираем все слова в исходном тексте
for token in max_er_filtered_tokens:
    # получаем нормальную форму текущего слова
    p = morph.parse(str(token))[0]
    if "NOUN" in p.tag:
        # добавляем его в массив c существительными
        max_er_noun_tokens.append(p.normal_form)
    elif "ADJF" in p.tag or "ADJS" in p.tag:
        # добавляем его в массив c прилагательными
        max_er_adjf_tokens.append(p.normal_form)
    elif "VERB" in p.tag or "INFN" in p.tag:
        # добавляем его в массив c глаголами
        max_er_verb_tokens.append(p.normal_form)

Чтобы не повторять код с построением облака подготовим функцию.

In [ ]:
def words_cloud(tokens):
  text = nltk.Text(tokens)
  text_raw = " ".join(text)
  # готовим размер картинки
  wordcloud = WordCloud(width=1600, height=800).generate(text_raw)
  plt.figure(figsize=(20,10), facecolor='k')
  # добавляем туда облако слов
  plt.imshow(wordcloud)
  # выключаем оси и подписи
  plt.axis("off")
  # убираем рамку вокруг
  plt.tight_layout(pad=0)
  # выводим картинку на экран
  plt.show()

И посмотрим на каждые части речи отдельно.

Существительные¶

ER <= median

In [ ]:
words_cloud(min_er_noun_tokens)

ER > median

In [ ]:
words_cloud(max_er_noun_tokens)

Набор слов очень похож, но как будто пользователи хуже взаимоействуют с постами о прокате фильмов.

Прилагательные¶

ER <= median

In [ ]:
words_cloud(min_er_adjf_tokens)

ER > median

In [ ]:
words_cloud(max_er_adjf_tokens)

По прилагательным ситуация также похожа, кажется, что пользователям меньше интересна информация о российских фильмах.

Глаголы¶

ER <= median

In [ ]:
words_cloud(min_er_verb_tokens)

ER > median

In [ ]:
words_cloud(max_er_verb_tokens)

По глаголам есть разница, что у постов с большим ER есть слово "появиться", что может говорить нам о том, что пользователям интересна информация о новинках.

Наборы слов очень похоиж, но как будто пользователи хуже взаимодействуют с постами о прокате фильмо и о отечественных картинах, но больше интересна информация о новинках.

Для дальнейшего удобства упакуем получение токенов в функцию.

In [ ]:
def get_word_tokens(text):

    """
    Функция для получения токенов существительных, прилагательных и глаголов текста,
    для последующего построения облака слов

    Args:
        text(str): текст, токены котоорого мы хотим получить

    Returns:
       text_noun_tokens(list):  токены существительных
       text_adjf_tokens:  токены прилагательных
       text_verb_tokens:  токены глаголов
    """
    # делаем все слова с маленькой буквы
    text = text.lower()
    # очищаем текст от знаков препинания
    text = "".join([ch for ch in text if ch not in spec_chars])
    # меняем переносы строк на пробелы
    text = re.sub('\n', ' ', text)
    # убираем из текста цифры
    text = "".join([ch for ch in text if ch not in string.digits])
    # токенизируем текст
    text_tokens = word_tokenize(text)

    #фильтруем от стоп-слов
    text_tokens = [token.strip() for token in text_tokens if token not in russian_stopwords]

    # добавляем анализатор слов
    morph = pymorphy2.MorphAnalyzer()
    # тут будут те же самые слова, что и в исходном тексте, но в нормальной форме
    text_filtered_tokens = []

    # перебираем все слова в исходном тексте

    for token in text_tokens:
        # получаем нормальную форму текущего слова
        p = morph.parse(str(token))[0]
        # добавляем его в новый массив
        text_filtered_tokens.append(p.normal_form)

    # новые переменные для существительных, прилагательных и глаголов
    text_noun_tokens = []
    text_adjf_tokens = []
    text_verb_tokens = []
    # перебираем все слова в исходном тексте
    for token in text_filtered_tokens:
        # получаем нормальную форму текущего слова
        p = morph.parse(str(token))[0]
        if "NOUN" in p.tag:
            # добавляем его в массив c существительными
            text_noun_tokens.append(p.normal_form)
        elif "ADJF" in p.tag or "ADJS" in p.tag:
            # добавляем его в массив c прилагательными
            text_adjf_tokens.append(p.normal_form)
        elif "VERB" in p.tag or "INFN" in p.tag:
            # добавляем его в массив c глаголами
            text_verb_tokens.append(p.normal_form)
    return text_noun_tokens, text_adjf_tokens, text_verb_tokens

Анализ комментариев¶

Посмотрим, какие комментарии пишут пользователи под постами с разной ER.

Для определения, тона комментариев (положительный, негативный или нейтральный) мы будем использовать библиотеки dostoevsky и FastTextSocialNetworkModel, к сожалению запустить их через блокнот не получилось, поэтому прилагаем скрипт app.py, с помощью которого выполнен анализ.
Загружаем полученный датафрейм.

In [ ]:
# подготавливаем ссылки для скачивания файлов с Яндекс.Диска.
# публичная ссылка на датасеты на яндекс диске
folder_url = 'https://disk.yandex.ru/d/nflbLG3sCHE-sg'
# названия файлов
file_url = ['comments_with_sentiment.csv']

# загружаем каждый файл в свой датафрейм
url = ('https://cloud-api.yandex.net/v1/disk/public/resources/download'
        + '?public_key='
        + urllib.parse.quote(folder_url)
        + '&path=/'
        + urllib.parse.quote(file_url[0]))
  # запрос ссылки на скачивание
r = requests.get(url)
   # 'парсинг' ссылки на скачивание
h = json.loads(r.text)['href']


comments_sentiment = pd.read_csv(h, index_col=[0])
In [ ]:
comments_sentiment = comments_sentiment.reset_index()
In [ ]:
print(comments_sentiment.info())
display(comments_sentiment.sample(5))
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 138902 entries, 0 to 138901
Data columns (total 5 columns):
 #   Column    Non-Null Count   Dtype  
---  ------    --------------   -----  
 0   post      132175 non-null  object 
 1   id        138902 non-null  int64  
 2   neutral   138902 non-null  float64
 3   negative  138902 non-null  float64
 4   positive  138902 non-null  float64
dtypes: float64(3), int64(1), object(1)
memory usage: 5.3+ MB
None
post id neutral negative positive
15147 Не ссылки не чего 36247 0.999457 0.095359 0.000000
113234 Неделю 31060 0.995255 0.001937 0.000000
35955 Через год будет сентябрь. Это повтор? Это стагнация? Это раздражает? 34811 0.952584 0.067557 0.000000
4831 Чувак хватит спорить пока не доказано не калышит,что сказано всё просто 36866 0.000000 0.637041 0.182436
53054 ну вот мне и интересно как там детям взрослые этот момент объясняют 33762 0.847978 0.000000 0.191943

В датафрейме 5 колонок и 138902 строк.

  • post - текст комментария,
  • id - id поста к которому был сделан комментарий,
  • neutral - уровень нейтральнсти поста от 0 до 1, где 1 точно нейтральный, а 0 точно не нейтральный,
  • negative - уровень негативности поста от 0 до 1, где 1 точно негативный, а 0 точно не негативный,
  • positive - уровень позитивности поста от 0 до 1, где 1 точно позитивный, а 0 точно не позитивный.
In [ ]:
plt.figure( figsize=(10,7))
comments_sentiment[['neutral', 'negative', 'positive']].boxplot()

plt.xlabel('Показатель')
plt.ylabel('Уровень')
plt.title('Уровень эмоциональной окраски комментариев')
plt.grid(alpha=1)
plt.show();

print('Уровень нейтральности комментариев')
print(comments_sentiment['neutral'].describe(percentiles=[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99]))
print()
print('Уровень негативности комментариев')
print(comments_sentiment['negative'].describe(percentiles=[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99]))
print()
print('Уровень позитивности комментариев')
print(comments_sentiment['positive'].describe(percentiles=[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99]))
Уровень нейтральности комментариев
count    138902.000000
mean          0.620568
std           0.358339
min           0.000000
1%            0.000000
5%            0.000000
25%           0.327678
50%           0.718604
75%           0.968866
95%           1.000010
99%           1.000010
max           1.000010
Name: neutral, dtype: float64

Уровень негативности комментариев
count    138902.000000
mean          0.097521
std           0.179495
min           0.000000
1%            0.000000
5%            0.000000
25%           0.000000
50%           0.000000
75%           0.144159
95%           0.484390
99%           0.822199
max           1.000010
Name: negative, dtype: float64

Уровень позитивности комментариев
count    138902.000000
mean          0.108561
std           0.235973
min           0.000000
1%            0.000000
5%            0.000000
25%           0.000000
50%           0.000000
75%           0.069552
95%           0.743178
99%           0.996065
max           1.000010
Name: positive, dtype: float64

Обычно, уровень негативности в комментариях от 0 до 0,35, в среднем 0.09, а медианное значение 0, т.е. половина комментариев точно не негативная.
Обычно, уровень позитива в комментариях от 0 до 0,19, в среднем 0.1, а медианное значение 0, т.е. половина комментариев точно не позитивные.
Обычно, уровень нейтральности в постах от 0 до 1, в среднем 0.62, а медианное значение 0,71, т.е. большая часть комментариев имеет нейтральный тон.
Выглядит так, что пользователи чаще всего отавляют нейтральные комментарие, а позитивные реже всего.

Посмотрим, разный ли уровень эмоциональной окраски у коментариев у постов с разным ER.

In [ ]:
er_min_ids = channel_posts_filtered.query('er <= @channel_posts_filtered.er.median()')['id']
er_max_ids = channel_posts_filtered.query('er > @channel_posts_filtered.er.median()')['id']
In [ ]:
fig, axes = plt.subplots(ncols=2, figsize = (10, 6))


# ER <= median
ax1 = sns.boxplot(data=comments_sentiment.query('id in @er_min_ids')[['neutral', 'negative', 'positive']],
                  color='#82E0AA',
                  ax=axes[0])
ax1.set(title = """Уровень эмоциональной окраски
  комментариев у постов
  с ER меньше или раным медианного значения""", ylabel = 'Уровень', xlabel = 'Показатель')
ax1.yaxis.grid(True)

# ER > median
ax2 = sns.boxplot(data=comments_sentiment.query('id in @er_max_ids')[['neutral', 'negative', 'positive']],
                  color='#85C1E9',
                  ax=axes[1])
ax2.set(title = """Уровень эмоциональной окраски
  комментариев у постов
  с ER больше медианного значения""", ylabel = 'Уровень', xlabel = 'Показатель')
ax2.yaxis.grid(True)

plt.show()

Выглядит так, как будто у постов с лучшей вовлеченностью уровень позитивности комментариев выше, посмотрим на цифры.

In [ ]:
print('Уровень позитивности комментариев у постов с ER меньше или равным медианного згначения')
print(comments_sentiment
      .query('id in @er_min_ids')['positive']
      .describe(percentiles=[0.5, 0.75, 0.95, 0.99]))
print()
print('Уровень позитивности комментариев у постов с ER больше медианного згначения')
print(comments_sentiment
      .query('id in @er_max_ids')['positive']
      .describe(percentiles=[0.5, 0.75, 0.95, 0.99]))
Уровень позитивности комментариев у постов с ER меньше или равным медианного згначения
count    34019.000000
mean         0.102544
std          0.227765
min          0.000000
50%          0.000000
75%          0.055015
95%          0.699264
99%          0.992848
max          1.000010
Name: positive, dtype: float64

Уровень позитивности комментариев у постов с ER больше медианного згначения
count    104883.000000
mean          0.110513
std           0.238542
min           0.000000
50%           0.000000
75%           0.073706
95%           0.754925
99%           0.996633
max           1.000010
Name: positive, dtype: float64

Можно сказать, что у постов с высокой вовлеченностью комментарии немного позитивнее, чем у постов с меньшей вовлеченностью.

Облако слов комментариев¶

Облака слов оказались почти идентичными, а выполнение кода занимает много времени и ресурсов, поэтому сейчас код закомментирован. При необходимости его можно раскомментировать и выполнить.

Посмотрим на облако слов коментариев

Обновим стоп-слова

In [ ]:
#russian_stopwords = stopwords.words("russian")
#russian_stopwords += stopwords.words("english")

Подготовим текст.

In [ ]:
#comments_sentiment['post'] = comments_sentiment['post'].astype('str')
#er_min_comments = ' '.join(comments_sentiment.query('id in @er_min_ids')['post'])
#er_max_comments = ' '.join(comments_sentiment.query('id in @er_max_ids')['post'])

Применим подготовленную ранее функцию для получения токенов.

In [ ]:
#er_min_com_noun_tokens, er_min_com_adjf_tokens, er_min_com_verb_tokens = get_word_tokens(er_min_comments)
#er_max_com_noun_tokens, er_max_com_adjf_tokens, er_max_com_verb_tokens = get_word_tokens(er_max_comments)

Существительные¶

ER меньше или равен медианному значению

In [ ]:
#words_cloud(er_min_com_noun_tokens)

ER больше медианного значения

In [ ]:
#words_cloud(er_max_com_noun_tokens)

Прилагательные¶

ER меньше или равен медианному значению

In [ ]:
#words_cloud(er_min_com_adjf_tokens)

ER больше медианного значения

In [ ]:
#words_cloud(er_max_com_adjf_tokens)

Глаголы¶

ER меньше или равен медианному значению

In [ ]:
#words_cloud(er_min_com_verb_tokens)

ER больше медианного значения

In [ ]:
#words_cloud(er_max_com_verb_tokens)

Разницы в словах у постов с разной вовлеченностью, можно сказать, нет.

Корреляция¶

Посмотрим, есть ли у нас корреляция между показателями постов.

In [ ]:
pd.plotting.scatter_matrix(channel_posts_filtered[['er',
                                                   'clear_symbols',
                                                   'clear_punctuation',
                                                   'forwarded',
                                                   'reactions_count',
                                                   'views',
                                                   'comments']], figsize=(9, 9));
In [ ]:
display(pd.DataFrame(channel_posts_filtered[['er',
                                             'clear_symbols',
                                             'clear_punctuation',
                                             'forwarded',
                                             'reactions_count',
                                             'views',
                                             'comments']].corr()).style.background_gradient('coolwarm'))
  er clear_symbols clear_punctuation forwarded reactions_count views comments
er 1.000000 -0.124445 -0.072238 0.630640 0.857685 0.096318 0.311890
clear_symbols -0.124445 1.000000 0.922682 -0.061784 -0.098189 -0.023614 -0.090965
clear_punctuation -0.072238 0.922682 1.000000 -0.032708 -0.057557 -0.030044 -0.065729
forwarded 0.630640 -0.061784 -0.032708 1.000000 0.477325 0.382870 0.214702
reactions_count 0.857685 -0.098189 -0.057557 0.477325 1.000000 0.354819 0.284498
views 0.096318 -0.023614 -0.030044 0.382870 0.354819 1.000000 0.264329
comments 0.311890 -0.090965 -0.065729 0.214702 0.284498 0.264329 1.000000

Т.к. Вовлеченность рассчитывается, как (Количество комментариев + колличество реакций + количество репостов) * 100 / количество просмотров, то корреляция с ними логична. И мы видим, что количество символов или количество знаков препинания практически не влияет ни на какой показатель.

Проверим корреляцию статистически.

In [ ]:
def df_corr_chec(df = channel_posts_filtered, first_par = 'er', second_par = 'clear_symbols', alpha=0.05):

    """
    Функция для подсчета коэффициентов корреляции Пирсона и Спирмана, а также их уровня значимости

    Args:
        df(pd.DataFrame): датафрейм,
        first_par(str): название столбца, который примем за первый параметр для сравнения
        second_par(str): название столбца, который примем за второй параметр сравнения

    Returns:
       coeffs(str):  коэффициенты Пирсона и спирмана, а также уровни значимости и вывод.
    """

    kp = pearsonr(df[first_par], df[second_par])
    ks = spearmanr(df[first_par], df[second_par])
    print(f'pearson_cor: {kp[0]}, pearson_pv: {kp[1]}')
    print(f'spearman_cor: {ks[0]}, spearman_pv: {ks[1]}')
    if kp[1] < alpha and ks[1] < alpha:
      print('Отвергаем нулевую гипотезу, есть основания считать что связь есть')
    elif kp[1] >= alpha and ks[1] >= alpha:
      print('Не отвергаем нулевую гипотезу, есть основания считать что связи нет')
    else:
      print('Нельзя однозначно сказать')

Нулевая гипотеза:ρ=0 (связи нет) Альтернативная гипотеза:ρ≠0 (связь есть)

In [ ]:
for i in ('clear_symbols', 'clear_punctuation'):
  print(i)
  df_corr_chec(channel_posts_filtered, 'er', i)
clear_symbols
pearson_cor: -0.1244446969521386, pearson_pv: 1.6977161270122093e-26
spearman_cor: -0.11829976695992515, spearman_pv: 4.3835860116619664e-24
Отвергаем нулевую гипотезу, есть основания считать что связь есть
clear_punctuation
pearson_cor: -0.07223813014170918, pearson_pv: 6.933268896032842e-10
spearman_cor: -0.06830974265548968, spearman_pv: 5.496611166956259e-09
Отвергаем нулевую гипотезу, есть основания считать что связь есть

Хоть статистически показывает, что связь есть, коэффициенты корреляции слишком небольшие, чтобы сказать однозначно.

Анализ эмодзи¶

Проанализируем эмодзи к постам.

Подготовим пустой датафрейм.

In [ ]:
emoji_df = pd.DataFrame(columns=['id', 'date', 'emoji', 'count'])

Подготовим функцию для получения id поста, его дату, эмодзи к нему и их количество

In [ ]:
def get_reactions(row):

    """
    Функция для получения id поста, его даты, реакций и их количества

    Args:
        row(pd.DataFrame): строка датафрейма,

    Returns:
       emoji_rows(pd.DataFrame):  датафрейм с id поста колонки, его даты, реакций и их количества.
    """
    emoji_rows = []
    try:
        reactions = ast.literal_eval(row['reactions'])
        for reaction in reactions['results']:
            emoji = reaction['reaction'].get('emoticon', None)
            count = reaction['count']
            emoji_rows.append({'id': row['id'], 'emoji': emoji, 'count': count, 'date':row['post_date']})
    except Exception as e:
        print(f"Error: {e}")
    return emoji_rows

Применим функцию.

In [ ]:
for index, row in channel_posts_filtered.iterrows():
    emoji_rows = get_reactions(row)
    emoji_df = emoji_df.append(emoji_rows, ignore_index=True)
Error: malformed node or string: nan
Error: malformed node or string: nan
Error: malformed node or string: nan
Error: malformed node or string: nan
Error: malformed node or string: nan
Error: malformed node or string: nan
Error: malformed node or string: nan
Error: malformed node or string: nan
Error: malformed node or string: nan
Error: malformed node or string: nan
Error: malformed node or string: nan
Error: malformed node or string: nan
Error: malformed node or string: nan
Error: malformed node or string: nan
Error: malformed node or string: nan
In [ ]:
print(emoji_df.info())
display(emoji_df.head())
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 56360 entries, 0 to 56359
Data columns (total 4 columns):
 #   Column  Non-Null Count  Dtype 
---  ------  --------------  ----- 
 0   id      56360 non-null  object
 1   date    56360 non-null  object
 2   emoji   56360 non-null  object
 3   count   56360 non-null  object
dtypes: object(4)
memory usage: 1.7+ MB
None
id date emoji count
0 37125 2024-01-21 ❤ 41
1 37125 2024-01-21 👍 8
2 37125 2024-01-21 🐳 4
3 37125 2024-01-21 👎 3
4 37125 2024-01-21 ❤‍🔥 1

В нашем распоряжении датафрейм с 4 колонками и 56359 строками.

  • id - id поста
  • date - дата поста
  • emoji - реакция к посту
  • count - количество этих реакций к посту.

Посотрим на ТОП10 самых популярных реакций.

In [ ]:
top_10_reactions = (emoji_df
                    .groupby(by='emoji', as_index=False)['count'].sum()
                    .sort_values(by='count', ascending=False)
                    .head(10))
display(top_10_reactions)
emoji count
18 👍 846086
2 ❤ 712377
19 👎 237739
29 🔥 229279
3 ❤‍🔥 135063
39 😢 115931
33 😁 58495
45 🤔 37173
25 💔 25383
56 🥰 21293

Абсолютным рекордсменом является палец вверх, на втором месте сердечко, а на третьем палец вниз.

Посмотрим, как менялись реакции по месяцам, для этого добавим столбец месяца к нашему датафрейму.

In [ ]:
emoji_df['month'] = pd.to_datetime(emoji_df['date'], format='%Y-%m-%d').dt.to_period('M')

И посчитаем сумму реакций топ3 самых популярных эмодзи по месяцам.

In [ ]:
temp = emoji_df.query('emoji in @top_10_reactions.head(3).emoji').pivot_table(
        index='month',
        values='count',
        aggfunc='sum',
        columns='emoji')
display(temp)
temp.plot(kind="bar", grid=True, figsize=(15, 5))
plt.xlabel('Месяц и год')
plt.title('Динамика топ3 реакций')
plt.ylabel('Количество реакций')
plt.legend(loc='upper left')
plt.show();
emoji ❤ 👍 👎
month
2021-12 246 62 2
2022-01 3322 16958 1380
2022-02 2618 16338 4945
2022-03 11231 41555 11523
2022-04 14184 45092 11257
2022-05 17065 51179 9465
2022-06 18118 41185 8290
2022-07 17729 42857 7344
2022-08 36200 51520 8946
2022-09 32074 48301 8623
2022-10 26708 57200 4986
2022-11 29163 59320 7456
2022-12 19000 34995 7500
2023-01 24577 29336 8058
2023-02 24156 25762 5223
2023-03 43197 38831 10732
2023-04 36660 20520 7885
2023-05 35394 27688 15695
2023-06 32954 20047 12198
2023-07 31859 25735 8076
2023-08 36944 20757 12253
2023-09 38941 29635 16692
2023-10 42592 26566 15980
2023-11 46231 29075 15534
2023-12 52876 24347 10932
2024-01 38338 21225 6764

Из-за недоступности кодировки в графике легенда не так понятна, но:

  • синий цвет - сердечко,
  • оранжевый цыет - палец вверх,
  • зеленый цвет - палец вниз.

Мы видим, что до марта 2023 года самой популрной реакцией был палец вверх, а после - сердечко.

Статистический анализ¶

Для статистического анализа подготовим функцию, для проведения теста Уилкоксона.

In [ ]:
def test_wilcoxon(group1,
                  group2,
                  alpha=0.05,
                  sample_volume=50,
                  multiplicity = 1,
                  correction = 'bonferrony'):
  """
    Функция для проверки статистической разницы долей тестом Уилкоксона

    Args:
        group1: первая группа сравнения
        group2: вторая группа сравнения
        alpha: критический уровень статистической значимости, по умолчанию 0.05
        bonferrony: поправка Бонферрони, по умолчанию 1
        sample_volume: размер выборки из групп для сравнения
        multiplicity: количество сравнений
        correction: выбор поправки, по умолчанию Бонферрони, альтернатива - Шидака

    Returns:
        print(p-значение).
        print(результат теста).
  """

  # делаем поправку значимости
  # на коэффициент Бонферрони, если выбран метод бонферони
  if correction == 'bonferrony':
    alpha = alpha / multiplicity
  # на поправку Шидака, если выбрано другое
  else:
    alpha = 1 - (1-alpha)**(1/multiplicity)

  # приводим наши группы в вид списка
  group1_list = group1.tolist()
  group2_list = group2.tolist()

  # подготавливаем пустые списки для сравнения
  sample_a =[]
  sample_b = []

  # создаем список индексов пустым
  counter = []

  # пока группы а выборки меньше заданного
  while len(sample_a) < sample_volume:

    # получаем случайное число от 0 до длины группы
    i = random.randrange(0, len(group1_list), 1)

    # если числа нет в списке индексов
    if i not in counter:

      # добавляем значение из группы в список для сравнения
      sample_a.append(group1_list[i])

      # добавляем ингдекс в счетчик
      counter.append(i)
    # если индекс есть в счетчике проолжаем дальше
    else:
      pass

  # еще раз создаем список индексов пустым
  counter = []

  # провеодим все тоже самое для группы 2
  while len(sample_b) < sample_volume:
    i = random.randrange(0, len(group2_list), 1)
    if i not in counter:
      sample_b.append(group2_list[i])
      counter.append(i)
    else:
      pass

  # выводитм значение p-value
  print('p-value равен', '{0:.5f}'.format(stats.wilcoxon(sample_a, sample_b)[1]))

  # если p-value больше alpha - не отвергаем нулевую гипотезу
  if stats.wilcoxon(sample_a, sample_b)[1] > alpha:
      print('Не отвергаем нулевую гипотезу, нет оснований считать группы разными')

  # если p-value меньше или равен alpha - отвергаем нулевую гипотезу
  else:
          print('Отвергаем нулевую гипотезу, есть основания считать группы разными')

  # выводим отношение показателей
  print('Отношение показателя группы B к A равно ',
        '{0:.3f}'.format(statistics.mean(sample_b) / statistics.mean(sample_a) - 1))

Выдвенем нулевую гипотезу, что статистически значимых различий в ER между группами нет.
Альтернативная гипотеза - статистически значимые различия в ER между группами есть.
Проверим ее методом Уилкоксона. Задаем уровень значимости alpha=0.05

In [ ]:
i = 'MessageMediaWebPage'
k = 'MessageMediaPhoto'
l = 'MessageMediaDocument'
print(f'ER группы {i} и группы {k}')
test_wilcoxon(channel_posts_filtered.query('type_attachment == @i')['er'],
             channel_posts_filtered.query('type_attachment == @k')['er'],
             alpha=0.05,
             sample_volume=100,
             multiplicity = 3,
             correction = 'bonferrony')
print()
print(f'ER группы {i} и группы {l}')
test_wilcoxon(channel_posts_filtered.query('type_attachment == @i')['er'],
             channel_posts_filtered.query('type_attachment == @l')['er'],
             alpha=0.05,
             sample_volume=100,
             multiplicity = 3,
             correction = 'bonferrony')
print()
print(f'ER группы {l} и группы {k}')
test_wilcoxon(channel_posts_filtered.query('type_attachment == @l')['er'],
             channel_posts_filtered.query('type_attachment == @k')['er'],
             alpha=0.05,
             sample_volume=100,
             multiplicity = 3,
             correction = 'bonferrony')
ER группы MessageMediaWebPage и группы MessageMediaPhoto
p-value равен 0.00000
Отвергаем нулевую гипотезу, есть основания считать группы разными
Отношение показателя группы B к A равно  0.517

ER группы MessageMediaWebPage и группы MessageMediaDocument
p-value равен 0.00000
Отвергаем нулевую гипотезу, есть основания считать группы разными
Отношение показателя группы B к A равно  0.653

ER группы MessageMediaDocument и группы MessageMediaPhoto
p-value равен 0.87704
Не отвергаем нулевую гипотезу, нет оснований считать группы разными
Отношение показателя группы B к A равно  0.151

Мы видим, что видимо есть статистическая разница между поставми с ссылкой и изобрадением а также с ссылкой и документом, при этом статистической разницы в ER между поствми с документом и фото - нет.

Вывод

  • В датасете 23326 постов, а с 30 декабря 2021 года было написано 7273 постов.
  • В канале бывает обычно от 1 до 21 постав в день, в среднем (и медианное значение) около 5 постов в день. Но бывают дни, когда пишутся аномально много постов - до 32.
  • Были периоды резкого подъема в районе августа 2022, февраля 2023 и огромнвый рост после ноября 2023 года, но также мы видим, что в сентябре 2023 года был период, когда количество подписчиков падало.
  • Обычно количество подписчиков в день меняется от приблизительно минус 200, до плюс тысячи.
  • Показатель вовлеченности (ER) в среднем около 0,85, медианное значение - 0,92. Аномально большим считается показатель больше 2, но есть рекордсмен - более 12.
  • Cо временем вовлеченность пользователей росла до мая месяца, после чего начала радать, а с июля начала расти до октября, как раз когда выпустили чат бота. Провал в июле можно объяснить тем, что телеграм планировал вводить систему рекомендаций по каналам из-за чего менял органические алгоритмы.
  • ERR обычно в пределах от 13,68 до 31, среднее значение по дням - 21,15, медианное - 20,29. Но есть 3 дня, когда ERR была больше 31, до 44,39,
  • Среднемесячная вовлеченность (ERR) росла с ноября 2022 по январь 2023 года, после чего, можно сказать, держалась на одном уровне, а после июля 2023 года резко пошла на спад. и Только в октябре 2023 года стала снвоа расти.
  • Обычно в постах бывает от 3 до приблизительно 900 символов, но также встречаются посты до 3998 символов. В среднем в постах 363 символа, медианное значение 279 символа.
  • В постах обычно бывает от 0 до 21 знаков препинания, но есть посты и до 120 знаков препинания.
  • 75% постов до 50 символов, вероятно, это анонсы, постеры, кадры из фильмов. 17% - посты обычной длины, 4% очень коротких постов и 3,4% очень длинных постов.
  • У коротких постов выше всего ER, количество просмотров и репостов. Но разница по просмотрам у категорий не очень большая. А вот ER у больших постов значительно ниже, чем у других категорий, возможно, пользователи ленятся читать большие тексты и поэтому и не взаимодействуют с ними.
  • Похоже, что пользователи хуже взаимодействуют с постами о прокате фильмов и о отечественных картинах, но больше интересна информация о новинках.
  • Выглядит так, что пользователи чаще всего отавляют нейтральные комментариев, а позитивные реже всего.
  • У постов с лучшей вовлеченностью уровень позитивности комментариев выше, посмотрим на цифры.
  • При этом в постах с большей и меньшей ER используют похожие слова.
  • количество символов или количество знаков препинания практически не влияет на ER.
  • По реакциям к постам абсолютным рекордсменом является палец вверх, на втором месте сердечко, а на третьем палец вниз.
  • До марта 2023 года самой популрной реакцией был палец вверх, а после - сердечко.
  • видимо есть статистическая разница между поставми с ссылкой и изобрадением а также с ссылкой и документом, при этом статистической разницы в ER между поствми с документом и фото - нет.

Анализ конкурентов¶

Wink¶

Одним из основных конкурентов кинопоиска со своим телеграм каналом о кино является wink, посмотрим на посты в его канале.
Т.к. работа с данными похожа, проведем ее кратко.

Загрузка и предобработка данных¶

Загрузим данные.

In [ ]:
# подготавливаем ссылки для скачивания файлов с Яндекс.Диска.
# публичная ссылка на датасеты на яндекс диске
folder_url = 'https://disk.yandex.ru/d/nflbLG3sCHE-sg'
# названия файлов
file_url = ['wink_posts_2024-02-21.csv']

# загружаем  файл в свой датафрейм
url = ('https://cloud-api.yandex.net/v1/disk/public/resources/download'
        + '?public_key='
        + urllib.parse.quote(folder_url)
        + '&path=/'
        + urllib.parse.quote(file_url[0]))
  # запрос ссылки на скачивание
r = requests.get(url)
   # 'парсинг' ссылки на скачивание
h = json.loads(r.text)['href']


wink = pd.read_csv(h, index_col=[0])

Уберем лишние колонки.

In [ ]:
wink = wink.drop(columns=['channel', 'with_media', 'replies'])
In [ ]:
display(wink.sample(2))
print(wink.info())
id date text views reactions forwarded reactions_count comments type_attachment
9784 160 2020-10-14 17:02:47+00:00 Чем еще порадует нас октябрь? Боем Александра Волкова!💥\n\nЗвезда ММА и бывший чемпион в тяжёлом весе в Bellator и M-1 Challenge на турнире UFC 254 выступит в одном карде Хабибом Нурмагомедовым. На «Бойцовском острове» Волков сразится с Уолтом Харрисом, и этот бой станет для него 40-м по счету.\n\n⚽️ Россиянин настроен решительно и каждый день готовится к поединку, тренируясь вместе с Максимом Гришиным. Ранее Волков уже был замечен в совместных тренировках, только тогда это был Артём Дзюба. Они давно дружат, и даже проводили друг другу тренировки по своим видам спорта: Волков — по ММА, а Дзюба — по футболу. Надеемся, что такая поддержка поможет победе Волкова в поединке на UFC!\n\nА как это будет, смотрите в трансляции на UFC ТВ! https://clck.ru/RPjEE\n\nУзнавайте больше о бойцах UFC из наших анкет и готовьтесь к 24 октября — главному поединку года Хабиб vs Гэтжи — вместе с Wink. 54.0 NaN 1.0 0 0 MessageMediaDocument
9050 962 2021-08-13 14:18:20+00:00 🗽 Камео — это небольшая роль известного человека или образа, которая заметно выделяется на фоне других небольших ролей в фильме. \n\nКоролем камео был Альфред Хичкок — он появлялся почти во всех своих фильмах. Появляться в кино в роли самого себя в 90-е любил и Дональд Трамп, тогда еще предприниматель и телеведущий: яркий пример можно увидеть в «Один дома 2: Затерянный в Нью-Йорке». Еще одним любителем камео был Стэн Ли, который сыграл более 50 небольших ролей в игровых и анимационных фильмах вселенной Marvel. 1376.0 NaN 0.0 0 0 MessageMediaDocument
<class 'pandas.core.frame.DataFrame'>
Int64Index: 4627 entries, 0 to 9913
Data columns (total 9 columns):
 #   Column           Non-Null Count  Dtype  
---  ------           --------------  -----  
 0   id               4627 non-null   int64  
 1   date             4627 non-null   object 
 2   text             4627 non-null   object 
 3   views            4627 non-null   float64
 4   reactions        3081 non-null   object 
 5   forwarded        4627 non-null   float64
 6   reactions_count  4627 non-null   int64  
 7   comments         4627 non-null   int64  
 8   type_attachment  4218 non-null   object 
dtypes: float64(2), int64(3), object(4)
memory usage: 361.5+ KB
None

В нашем расположении датафрейм с 8 колонками и 9913 строками.

  • id - id поста,
  • date - дата написания поста,
  • text - текст поста,
  • views - количество просмотров поста,
  • reactions - количество реакций на посте,
  • forwarded - количество репостов поста,
  • reactions_count - количество реакций на посте (сумма всех),
  • comments - количество комментариев,
  • type_attachment - тип приложения к посту (файл, фото, видео).

Добавим колонку date_time с датой и временем поста в формате datetime64, post_date с датой написания поста, а также er c er поста (все взаимодействия с постом на количество просмотров, умноженное на 100).

In [ ]:
wink['date_time']= pd.to_datetime(wink['date'], format="%Y-%m-%d %H:%M:%S%z", utc=True).dt.tz_convert('Europe/Moscow')
wink['post_date'] = pd.to_datetime(wink['date_time']).dt.date

wink_filtered = wink.query('@analyzes_date < post_date <=   @channel_posts.post_date.max()')
wink_filtered['er'] = ((wink_filtered['reactions_count']
                                          + wink_filtered['comments']
                                          + wink_filtered['forwarded'])
                                          * 100
                                          /wink_filtered['views'] ).round(2)

Количество написанных постов в канале¶

Посмотрим, как часто писали посты в канале.

In [ ]:
# посчитаем сколько в каждую дату было написано постов
wink_posts_dimanic = wink.groupby(by='post_date').agg({'id':'count'}).reset_index().sort_values(by='post_date')
# посчитаем кумулятивную сумму, т.е. с накоплением

wink_posts_dimanic['posts_cum'] = wink_posts_dimanic['id'].cumsum()

# посчитаем сколько в каждую дату было написано постов с 30 декаря 2021 года
filter_wink_posts_dimanic = (wink_filtered
  .groupby(by='post_date')
  .agg({'id':'count'})
  .reset_index()
  .sort_values(by='post_date')
  .rename(columns={'id':'posts_per_day'}))

# посчитаем кумулятивную сумму, т.е. с накоплением
filter_wink_posts_dimanic['posts_cum'] = filter_wink_posts_dimanic['posts_per_day'].cumsum()
In [ ]:
fig, ax = plt.subplots(1, 2, figsize=(12,6))

fig.suptitle('Количество постов в канале', fontsize=20)

ax[0].plot(wink_posts_dimanic['post_date'], wink_posts_dimanic['posts_cum'])
ax[0].set_title('Количество написанных постов за все время')
ax[0].set(xlabel='Дата', ylabel='Количество написанных постов к дате')
ax[0].tick_params(axis='x', labelrotation=45)
ax[0].grid(True)


ax[1].plot(filter_wink_posts_dimanic['post_date'], filter_wink_posts_dimanic['posts_cum'])
ax[1].set_title(f'Количество написанных постов c {analyzes_date}')
ax[1].set(xlabel='Дата', ylabel='Количество написанных постов к дате')
ax[1].tick_params(axis='x', labelrotation=45)
ax[1].grid(True)
fig.show();

Посты в канале пишутся довольно равномерно, за анализируемый период было написано около 3600 постов.

ER по времени¶

Посмотрим медианный ER по дням

In [ ]:
wink_er_day = wink_filtered.groupby('post_date', as_index=False).agg({'er':'median'}).sort_values(by='post_date')
In [ ]:
plt.figure(figsize=(15, 5))
plt.title('Вовлеченность подписчиков (ER) по дням')

plt.plot(wink_er_day['post_date'], wink_er_day['er'])
plt.xlabel('Дата')
plt.ylabel('er')
plt.grid(True)
plt.show();

Посмотрим медианный ER по месяцам.

In [ ]:
wink_er_day['month'] = pd.to_datetime(wink_er_day['post_date'], format='%Y-%m-%d').dt.to_period("M")
wink_er_month = wink_er_day.groupby('month', as_index=False).agg({'er':'median'}).sort_values(by='month')
wink_er_month['month'] = wink_er_month['month'].astype('str')
plt.figure(figsize=(15, 5))
plt.title('Вовлеченность подписчиков (ER) по месяцам')
plt.plot(wink_er_month['month'], wink_er_month['er'])
plt.xlabel('Месяц')
plt.tick_params(axis='x', labelrotation=45)
plt.ylabel('er')
plt.grid(True)
plt.show();

Мы видим, что был небольшой пик в ноябре 2022, после чего падение, затем плавный рост до апреля 2023, и снова падение плавное, и резкий рост с ноября 2023.

In [ ]:
wink_filtered['post_hour'] = wink_filtered['date_time'].dt.hour
wink_filtered.groupby(by='post_hour')['er'].median().plot(kind='bar',
       figsize=(12,5),
       title='Медианный ER по времени написания поста',
       xlabel='Время',
       ylabel='Медианный ER',
       rot=0
      );

Мы видим, что медианный ER выше у постов в польночь, но это может быть связано с тем, что ночью пишут мало постов, проверим.

In [ ]:
wink_filtered.groupby(by='post_hour')['er'].count().plot(kind='bar',
       figsize=(12,5),
       title='Количество постов в течение дня',
       xlabel='Время',
       ylabel='Количество написанных постов',
       rot=0
      );

Да, действительно, в канале почти нет постов с 10 вечера до 9 утра.

Посмотрим на медианный ER по дням недели.

In [ ]:
wink_filtered['post_weekday'] = wink_filtered['date_time'].dt.dayofweek
wink_filtered.groupby(by='post_weekday')['er'].median().plot(kind='bar',
       figsize=(12,5),
       title='Медианный ER по дню недели написания поста',
       xlabel='День недели',
       ylabel='Медианный ER',
       rot=0
      );

Можно сказать, что ER почти равен по дням недели, есть падение сильное только в воскресенье, а больше всего в понедельник.

Анализ текста постов¶

Посчитаем количество символов и знаков препинания в текстах постов.

In [ ]:
clean_data(wink_filtered)
wink_filtered['clear_symbols'] = wink_filtered['clean_text'].apply(lambda x: len(x))
wink_filtered['clear_punctuation'] = wink_filtered['clean_text'].apply(lambda x: sum(map(x.count, [',',
                                                                                                    '.',
                                                                                                    '—',
                                                                                                    ':',
                                                                                                    ';',
                                                                                                    '-',
                                                                                                    '!',
                                                                                                    '?'])))

Разобьем посты по количеству символов.

In [ ]:
wink_filtered['post_cat'] = wink_filtered['clear_symbols'].apply(post_volume)
display(wink_filtered.groupby(by='post_cat', as_index=False)['id'].count())

wink_filtered['post_cat'].value_counts().plot(kind="pie",
          autopct='%1.1f%%',
          explode=[0.05, 0.01, 0.01, 0.01],
          legend=True,
          title='Доля постов по категориям',
          ylabel='',
          labeldistance=None,
          figsize=(6, 6),
          cmap='Pastel2').legend(bbox_to_anchor=(1.5, 1));
post_cat id
0 длинный пост 139
1 короткий пост 2567
2 обычный пост 748
3 очень короткий пост 133

Мы видим, что 71,6% постов были короткими, на втором месте посты обычной длины (до 1000 символов) - 20,9%, 3,9% - длинные посты, 3,7% - очень короткие посты.

In [ ]:
wink_filtered.groupby(by='post_cat')['er'].median().sort_values(ascending=False).plot(kind='bar',
       figsize=(12,5),
       title='Медианный ER по категориям постов',
       xlabel='Категория поста',
       ylabel='Медианный ER',
       rot=0
      );

У очень коротких постов выше медианный охват, это логично, так выкладывают трейлеры, постеры, кадры из фильмов, срочные новости. Разница с кинопоиском в том, что медианный ахват в этом канале выше у длинных постов, чем у постов с обычной длиной.

In [ ]:
wink_filtered.groupby(by='post_cat')['views'].median().sort_values(ascending=False).plot(kind='bar',
       figsize=(12,5),
       title='Медианное количество просмотров по категориям постов',
       xlabel='Категория поста',
       ylabel='Медианное количество просмотров',
       rot=0
      );

Также просмотров больше всего у очень коротких постов, а у длинных постов меньше всего.

In [ ]:
fig, axes = plt.subplots(nrows=4, figsize = (10, 15))
fig.tight_layout(pad=5.0)

# ER
ax1 = sns.boxplot(x='er', y='post_cat', data=wink_filtered, color='#82E0AA', ax=axes[0])
ax1.set(title = 'ER по категориям постов', ylabel = 'Категория', xlabel = 'ER')

# Просмотры
ax2 = sns.boxplot(x='views', y='post_cat', data=wink_filtered, color='#85C1E9', ax=axes[1])
ax2.set(title = 'Просмотов по категориям постов', ylabel = 'Категория', xlabel = 'Количество просмотров')

# Репосты
ax3 = sns.boxplot(x='forwarded', y='post_cat', data=wink_filtered, color='#F5B7B1', ax=axes[2])
ax3.set(title = 'Репостов по категориям постов', ylabel = 'Категория', xlabel = 'Количество репостов')

# Комментарии
ax4 = sns.boxplot(x='comments', y='post_cat', data=wink_filtered, color='#FAD7A0', ax=axes[3])
ax4.set(title = 'Комментариев по категориям постов', ylabel = 'Категория', xlabel = 'Количество комментариев')

plt.show()
In [ ]:
fig, axes = plt.subplots(nrows=4, figsize = (10, 15))
fig.tight_layout(pad=5.0)

# ER
ax1 = sns.boxplot(x='er', y='post_cat', data=wink_filtered, color='#82E0AA', ax=axes[0], showfliers=False)
ax1.set(title = 'ER по категориям постов', ylabel = 'Категория', xlabel = 'ER')

# Просмотры
ax2 = sns.boxplot(x='views', y='post_cat', data=wink_filtered, color='#85C1E9', ax=axes[1], showfliers=False)
ax2.set(title = 'Просмотов по категориям постов', ylabel = 'Категория', xlabel = 'Количество просмотров')

# Репосты
ax3 = sns.boxplot(x='forwarded', y='post_cat', data=wink_filtered, color='#F5B7B1', ax=axes[2], showfliers=False)
ax3.set(title = 'Репостов по категориям постов', ylabel = 'Категория', xlabel = 'Количество репостов')

# Комментарии
ax4 = sns.boxplot(x='comments', y='post_cat', data=wink_filtered, color='#FAD7A0', ax=axes[3], showfliers=False)
ax4.set(title = 'Комментариев по категориям постов', ylabel = 'Категория', xlabel = 'Количество комментариев')

plt.show()

Пользователям интереснее всего короткие и очень короткие посты, они с ними больше всего взаимодейстуют.

In [ ]:
min_er_text_wink = ' '.join(wink_filtered.query('er <= @wink_filtered.er.median()')['clean_text'])
max_er_text_wink = ' '.join(wink_filtered.query('er > @wink_filtered.er.median()')['clean_text'])
In [ ]:
wink_er_min_noun_tokens, wink_er_min_adjf_tokens, wink_er_min_verb_tokens = get_word_tokens(min_er_text_wink)
In [ ]:
wink_er_max_noun_tokens, wink_er_max_adjf_tokens, wink_er_max_verb_tokens = get_word_tokens(max_er_text_wink)
Существительные¶

ER меньше или равен медианному значению

In [ ]:
words_cloud(wink_er_min_noun_tokens)

ER больше медианного значения

In [ ]:
words_cloud(wink_er_max_noun_tokens)

Выглядит так, что пользователям этого канала больше нравятся посты о сериалах, особенно о слове пацана.

Прилагательные¶

ER меньше или равен медианному значению

In [ ]:
words_cloud(wink_er_min_adjf_tokens)

ER больше медианного значения

In [ ]:
words_cloud(wink_er_max_adjf_tokens)

В прилагательных нет разницы.

Глаголы¶

ER меньше или равен медианному значению

In [ ]:
words_cloud(wink_er_min_verb_tokens)

ER больше медианного значения

In [ ]:
words_cloud(wink_er_max_verb_tokens)

В глаголах тоже, можно сказать, нет разницы.

Тип вложения в постах¶

Посмотрим, как влияет тип вложения на ER.

Заполним пропуски в типе вложения "without_attachment", т.е. без вложения.

In [ ]:
wink_filtered['type_attachment'] = wink_filtered['type_attachment'].fillna('without_attachment')

Посчитаем количество постов по типу вложения и их медианный ER.

In [ ]:
wink_attach = wink_filtered.groupby(by='type_attachment').agg({'id':'count', 'er':'median'})
display(wink_attach)
id er
type_attachment
MessageMediaDocument 741 1.210
MessageMediaPhoto 2090 0.640
MessageMediaWebPage 418 0.635
without_attachment 338 0.345
In [ ]:
wink_attach['id'].sort_values(ascending=False).plot(kind="pie",
          autopct='%1.1f%%',
          explode=[0.05, 0.01, 0.01, 0.01],
          legend=True,
          title='Доля постов по вложениям',
          ylabel='',
          labeldistance=None,
          figsize=(6, 6),
          cmap='Pastel2').legend(bbox_to_anchor=(1.5, 1));

Более половины (58,3%) постов в канале с картинками, 20,7% с документом, меньше всего (9,4%) постов без вложения.

In [ ]:
wink_attach['er'].sort_values(ascending=False).plot(kind='bar',
       figsize=(12,5),
       title='Медианное ER просмотров по типу вложения',
       xlabel='Тип вложения',
       ylabel='Медианное ER',
       rot=0
      );

При этом наибольший медианный ER у постов с документами (почти в 2 раза, по сравнению с изображениями и ссылками), наименьший медианный ER у постов без вложения.

Вывод

  • В нашем расположении датафрейм с 8 колонками и 9913 строками.
  • Посты в канале пишутся довольно равномерно, за анализируемый период было написано около 3600 постов.
  • Был небольшой пик ER в ноябре 2022, после чего падение, затем плавный рост до апреля 2023, и снова падение плавное, и резкий рост с ноября 2023.
  • В канале почти нет постов с 10 вечера до 9 утра.
  • Можно сказать, что ER почти равен по дням недели, есть падение сильное только в воскресенье, а больше всего в понедельник.
  • 71,6% постов были короткими, на втором месте посты обычной длины (до 1000 символов) - 20,9%, 3,9% - длинные посты, 3,7% - очень короткие посты.
  • У очень коротких постов выше медианный охват, это логично, так выкладывают трейлеры, постеры, кадры из фильмов, срочные новости. Разница с кинопоиском в том, что медианный ахват в этом канале выше у длинных постов, чем у постов с обычной длиной.
  • Просмотров больше всего у очень коротких постов, а у длинных постов меньше всего.
  • Пользователям интереснее всего короткие и очень короткие посты, они с ними больше всего взаимодейстуют.
  • Выглядит так, что пользователям этого канала больше нравятся посты о сериалах, особенно о слове пацана.
  • Более половины (58,3%) постов в канале с картинками, 20,7% с документом, меньше всего (9,4%) постов без вложения.
  • При этом наибольший медианный ER у постов с документами (почти в 2 раза, по сравнению с изображениями и ссылками), наименьший медианный ER у постов без вложения.

kinoreel¶

Также посмотрим на непрямого конкурента в бизнессе, но тоже крупный канал про кино в телеграме.

In [ ]:
# подготавливаем ссылки для скачивания файлов с Яндекс.Диска.
# публичная ссылка на датасеты на яндекс диске
folder_url = 'https://disk.yandex.ru/d/nflbLG3sCHE-sg'
# названия файлов
file_url = ['kinoreel_posts_2024-02-21.csv']

# загружаем  файл в свой датафрейм
url = ('https://cloud-api.yandex.net/v1/disk/public/resources/download'
        + '?public_key='
        + urllib.parse.quote(folder_url)
        + '&path=/'
        + urllib.parse.quote(file_url[0]))
  # запрос ссылки на скачивание
r = requests.get(url)
   # 'парсинг' ссылки на скачивание
h = json.loads(r.text)['href']


kinoreel = pd.read_csv(h, index_col=[0])
In [ ]:
kinoreel = kinoreel.drop(columns=['channel', 'with_media', 'replies'])
In [ ]:
print(kinoreel.info())
display(kinoreel.sample(2))
<class 'pandas.core.frame.DataFrame'>
Int64Index: 2177 entries, 0 to 3925
Data columns (total 9 columns):
 #   Column           Non-Null Count  Dtype  
---  ------           --------------  -----  
 0   id               2177 non-null   int64  
 1   date             2177 non-null   object 
 2   text             2177 non-null   object 
 3   views            2177 non-null   float64
 4   reactions        2147 non-null   object 
 5   forwarded        2177 non-null   float64
 6   reactions_count  2177 non-null   int64  
 7   comments         2177 non-null   int64  
 8   type_attachment  2159 non-null   object 
dtypes: float64(2), int64(3), object(4)
memory usage: 170.1+ KB
None
id date text views reactions forwarded reactions_count comments type_attachment
3924 3 2022-04-05 07:05:16+00:00 По сводкам портала Deadline, актриса Сидни Суидни, известная по роли Кесси в нашумевшем подростковом сериале “Эйфория”, присоединилась к касту экранизации комикса Marvel «Мадам Паутина». Главную роль, как уже известно, сыграет звезда «50 оттенков серого» Дакота Джонсон, роль Суини пока неизвестна. \n\nМадам Паутина, она же Кассандра Уэбб, обладает экстрасенсорными способностями и даром ясновидения, которые позволили ей стать медиумом. Именно Мадам Паутина раскрыла личность Питера Паркера в комиксах, но при таком большом вкладе героиня всё равно оставалась второстепенной. \n\nКак будет выглядеть сольный проект и каких ещё героев увидим на экране – узнаем позднее. 81706.0 {'_': 'MessageReactions', 'results': [{'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👍'}, 'count': 125, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤'}, 'count': 18, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👎'}, 'count': 7, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🤔'}, 'count': 7, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🦄'}, 'count': 7, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '😭'}, 'count': 3, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '🙏'}, 'count': 2, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👾'}, 'count': 1, 'chosen_order': None}], 'min': False, 'can_see_list': False, 'reactions_as_tags': False, 'recent_reactions': []} 16.0 170 10 MessageMediaPhoto
1192 2828 2023-08-24 05:02:09+00:00 Доброе утро, жители KinoReel! \nПоделитесь в комментариях любимыми киномемами, давайте поднимем друг другу настроение :) 14268.0 {'_': 'MessageReactions', 'results': [{'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👍'}, 'count': 52, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '😁'}, 'count': 37, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '❤'}, 'count': 10, 'chosen_order': None}, {'_': 'ReactionCount', 'reaction': {'_': 'ReactionEmoji', 'emoticon': '👌'}, 'count': 4, 'chosen_order': None}], 'min': False, 'can_see_list': False, 'reactions_as_tags': False, 'recent_reactions': []} 24.0 103 13 MessageMediaPhoto
In [ ]:
kinoreel['date_time']= pd.to_datetime(kinoreel['date'], format="%Y-%m-%d %H:%M:%S%z", utc=True).dt.tz_convert('Europe/Moscow')
kinoreel['post_date'] = pd.to_datetime(kinoreel['date_time']).dt.date

kinoreel_filtered = kinoreel.query('@analyzes_date < post_date <=   @channel_posts.post_date.max()')
kinoreel_filtered['er'] = ((kinoreel_filtered['reactions_count']
                                          + kinoreel_filtered['comments']
                                          + kinoreel_filtered['forwarded'])
                                          * 100
                                          /kinoreel_filtered['views'] ).round(2)

Количество написанных постов в канале¶

In [ ]:
# посчитаем сколько в каждую дату было написано постов
kinoreel_posts_dimanic = kinoreel.groupby(by='post_date').agg({'id':'count'}).reset_index().sort_values(by='post_date')
# посчитаем кумулятивную сумму, т.е. с накоплением

kinoreel_posts_dimanic['posts_cum'] = kinoreel_posts_dimanic['id'].cumsum()

# посчитаем сколько в каждую дату было написано постов с 30 декаря 2021 года
filter_kinoreel_posts_dimanic = (kinoreel_filtered
  .groupby(by='post_date')
  .agg({'id':'count'})
  .reset_index()
  .sort_values(by='post_date')
  .rename(columns={'id':'posts_per_day'}))

# посчитаем кумулятивную сумму, т.е. с накоплением
filter_kinoreel_posts_dimanic['posts_cum'] = filter_kinoreel_posts_dimanic['posts_per_day'].cumsum()
In [ ]:
fig, ax = plt.subplots(1, 2, figsize=(12,6))

fig.suptitle('Количество постов в канале', fontsize=20)

ax[0].plot(kinoreel_posts_dimanic['post_date'], kinoreel_posts_dimanic['posts_cum'])
ax[0].set_title('Количество написанных постов за все время')
ax[0].set(xlabel='Дата', ylabel='Количество написанных постов к дате')
ax[0].tick_params(axis='x', labelrotation=45)
ax[0].grid(True)


ax[1].plot(filter_kinoreel_posts_dimanic['post_date'], filter_kinoreel_posts_dimanic['posts_cum'])
ax[1].set_title(f'Количество написанных постов c {analyzes_date}')
ax[1].set(xlabel='Дата', ylabel='Количество написанных постов к дате')
ax[1].tick_params(axis='x', labelrotation=45)
ax[1].grid(True)
fig.show();
In [ ]:
kinoreel_er_day = kinoreel_filtered.groupby('post_date', as_index=False).agg({'er':'mean'}).sort_values(by='post_date')
In [ ]:
plt.figure(figsize=(15, 5))
plt.title('Вовлеченность подписчиков (ER) по дням')

plt.plot(kinoreel_er_day['post_date'], kinoreel_er_day['er'])
plt.xlabel('Дата')
plt.ylabel('er')
plt.grid(True)
plt.show();
In [ ]:
kinoreel_er_day['month'] = pd.to_datetime(kinoreel_er_day['post_date'], format='%Y-%m-%d').dt.to_period("M")
kinoreel_er_month = kinoreel_er_day.groupby('month', as_index=False).agg({'er':'median'}).sort_values(by='month')
kinoreel_er_month['month'] = kinoreel_er_month['month'].astype('str')
plt.figure(figsize=(15, 5))
plt.title('Вовлеченность подписчиков (ER) по месяцам')
plt.plot(kinoreel_er_month['month'], kinoreel_er_month['er'])
plt.xlabel('Месяц')
plt.tick_params(axis='x', labelrotation=45)
plt.ylabel('er')
plt.grid(True)
plt.show();
In [ ]:
kinoreel_filtered['post_hour'] = kinoreel_filtered['date_time'].dt.hour
kinoreel_filtered.groupby(by='post_hour')['er'].median().plot(kind='bar',
       figsize=(12,5),
       title='Медианный ER по времени написания поста',
       xlabel='Время',
       ylabel='Медианный ER',
       rot=0
      );
In [ ]:
kinoreel_filtered['post_weekday'] = kinoreel_filtered['date_time'].dt.dayofweek
kinoreel_filtered.groupby(by='post_weekday')['er'].median().plot(kind='bar',
       figsize=(12,5),
       title='Медианный ER по дню недели написания поста',
       xlabel='День недели',
       ylabel='Медианный ER',
       rot=0
      );
In [ ]:
clean_data(kinoreel_filtered)
kinoreel_filtered['clear_symbols'] = kinoreel_filtered['clean_text'].apply(lambda x: len(x))
kinoreel_filtered['clear_punctuation'] = kinoreel_filtered['clean_text'].apply(lambda x: sum(map(x.count, [',',
                                                                                                    '.',
                                                                                                    '—',
                                                                                                    ':',
                                                                                                    ';',
                                                                                                    '-',
                                                                                                    '!',
                                                                                                    '?'])))
In [ ]:
kinoreel_filtered['post_cat'] = kinoreel_filtered['clear_symbols'].apply(post_volume)
display(kinoreel_filtered.groupby(by='post_cat', as_index=False)['id'].count())

kinoreel_filtered['post_cat'].value_counts().plot(kind="pie",
          autopct='%1.1f%%',
          explode=[0.05, 0.01, 0.01, 0.01],
          legend=True,
          title='Доля постов по категориям',
          ylabel='',
          labeldistance=None,
          figsize=(6, 6),
          cmap='Pastel2').legend(bbox_to_anchor=(1.5, 1));
post_cat id
0 длинный пост 222
1 короткий пост 959
2 обычный пост 663
3 очень короткий пост 167
In [ ]:
kinoreel_filtered.groupby(by='post_cat')['er'].median().sort_values(ascending=False).plot(kind='bar',
       figsize=(12,5),
       title='Медианный ER по категориям постов',
       xlabel='Категория поста',
       ylabel='Медианный ER',
       rot=0
      );
In [ ]:
kinoreel_filtered.groupby(by='post_cat')['views'].median().sort_values(ascending=False).plot(kind='bar',
       figsize=(12,5),
       title='Медианное количество просмотров по категориям постов',
       xlabel='Категория поста',
       ylabel='Медианное количество просмотров',
       rot=0
      );
In [ ]:
fig, axes = plt.subplots(nrows=4, figsize = (10, 15))
fig.tight_layout(pad=5.0)

# ER
ax1 = sns.boxplot(x='er', y='post_cat', data=kinoreel_filtered, color='#82E0AA', ax=axes[0])
ax1.set(title = 'ER по категориям постов', ylabel = 'Категория', xlabel = 'ER')

# Просмотры
ax2 = sns.boxplot(x='views', y='post_cat', data=kinoreel_filtered, color='#85C1E9', ax=axes[1])
ax2.set(title = 'Просмотов по категориям постов', ylabel = 'Категория', xlabel = 'Количество просмотров')

# Репосты
ax3 = sns.boxplot(x='forwarded', y='post_cat', data=kinoreel_filtered, color='#F5B7B1', ax=axes[2])
ax3.set(title = 'Репостов по категориям постов', ylabel = 'Категория', xlabel = 'Количество репостов')

# Комментарии
ax4 = sns.boxplot(x='comments', y='post_cat', data=kinoreel_filtered, color='#FAD7A0', ax=axes[3])
ax4.set(title = 'Комментариев по категориям постов', ylabel = 'Категория', xlabel = 'Количество комментариев')

plt.show()
In [ ]:
fig, axes = plt.subplots(nrows=4, figsize = (10, 15))
fig.tight_layout(pad=5.0)

# ER
ax1 = sns.boxplot(x='er', y='post_cat', data=kinoreel_filtered, color='#82E0AA', ax=axes[0], showfliers=False)
ax1.set(title = 'ER по категориям постов', ylabel = 'Категория', xlabel = 'ER')

# Просмотры
ax2 = sns.boxplot(x='views', y='post_cat', data=kinoreel_filtered, color='#85C1E9', ax=axes[1], showfliers=False)
ax2.set(title = 'Просмотов по категориям постов', ylabel = 'Категория', xlabel = 'Количество просмотров')

# Репосты
ax3 = sns.boxplot(x='forwarded', y='post_cat', data=kinoreel_filtered, color='#F5B7B1', ax=axes[2], showfliers=False)
ax3.set(title = 'Репостов по категориям постов', ylabel = 'Категория', xlabel = 'Количество репостов')

# Комментарии
ax4 = sns.boxplot(x='comments', y='post_cat', data=kinoreel_filtered, color='#FAD7A0', ax=axes[3], showfliers=False)
ax4.set(title = 'Комментариев по категориям постов', ylabel = 'Категория', xlabel = 'Количество комментариев')

plt.show()
In [ ]:
min_er_text_kinoreel = ' '.join(kinoreel_filtered.query('er <= @kinoreel_filtered.er.median()')['clean_text'])
max_er_text_kinoreel = ' '.join(kinoreel_filtered.query('er > @kinoreel_filtered.er.median()')['clean_text'])
In [ ]:
kinoreel_er_min_noun_tokens, kinoreel_er_min_adjf_tokens, kinoreel_er_min_verb_tokens = get_word_tokens(min_er_text_kinoreel)
In [ ]:
kinoreel_er_max_noun_tokens, kinoreel_er_max_adjf_tokens, kinoreel_er_max_verb_tokens = get_word_tokens(max_er_text_kinoreel)

Существительные¶

ER меньше или равен медианному значению

In [ ]:
words_cloud(kinoreel_er_min_noun_tokens)

ER больше медианного значения

In [ ]:
words_cloud(kinoreel_er_max_noun_tokens)

Прилагательные¶

ER меньше или равен медианному значению

In [ ]:
words_cloud(kinoreel_er_min_adjf_tokens)

ER больше медианного значения

In [ ]:
words_cloud(kinoreel_er_max_adjf_tokens)

Глаголы¶

ER меньше или равен медианному значению

In [ ]:
words_cloud(kinoreel_er_min_verb_tokens)

ER больше медианного значения

In [ ]:
words_cloud(kinoreel_er_max_verb_tokens)
In [ ]:
kinoreel_filtered['type_attachment'] = kinoreel_filtered['type_attachment'].fillna('without_attachment')
In [ ]:
kinoreel_attach = kinoreel_filtered.groupby(by='type_attachment').agg({'id':'count', 'er':'median'})
In [ ]:
kinoreel_attach
Out[ ]:
id er
type_attachment
MessageMediaDocument 291 1.36
MessageMediaPhoto 1450 1.09
MessageMediaWebPage 253 0.84
without_attachment 17 0.64
In [ ]:
kinoreel_attach['id'].sort_values(ascending=False).plot(kind="pie",
          autopct='%1.1f%%',
          explode=[0.05, 0.01, 0.01, 0.01],
          legend=True,
          title='Доля постов по вложениям',
          ylabel='',
          labeldistance=None,
          figsize=(6, 6),
          cmap='Pastel2').legend(bbox_to_anchor=(1.5, 1));
In [ ]:
kinoreel_attach['er'].sort_values(ascending=False).plot(kind='bar',
       figsize=(12,5),
       title='Медианное ER просмотров по типу вложения',
       xlabel='Тип вложения',
       ylabel='Медианное ER',
       rot=0
      );